在登入畫面顯示獨立的密碼表單,以沿用瀏覽器內建密碼功能登入
// ==UserScript==
// @name PTT 半自動登入
// @description 在登入畫面顯示獨立的密碼表單,以沿用瀏覽器內建密碼功能登入
// @version 1.1.0
// @license MIT
// @author bootleq
// @namespace bootleq.com
// @homepageURL https://github.com/bootleq/user-scripts
//
// @match https://term.ptt.cc/*
// @run-at document-end
// @noframes
// ==/UserScript==
// References:
// https://github.com/c910335/PTT-Chrome-Auto-Login
// https://greasyfork.org/zh-TW/scripts/35360-term-ptt-autologin
// https://greasyfork.org/zh-TW/scripts/368445-term-ptt-cc-自動登入
// https://greasyfork.org/zh-TW/scripts/372391-pttchrome-term-ptt-cc-add-on
// https://hidde.blog/making-password-managers-play-ball-with-your-login-form/
const loginQuestionClass = 'q7 b0'; // 登入頁訊息的 className
const loginQuestionText = '請輸入代號,或以 guest 參觀,或以 new 註冊: '; // 登入頁訊息的文字(注意包含末尾空白)
const disconnAlertText = '你斷線了!'; // 登入頁斷線提示框的文字(偵測用)
const findQuestionTimeout = 6000; // 偵測登入頁的等待時間(ms),逾時則放棄
const containerId = 'PTTSemiLogin'; // 插入表單的 HTML id
const dialogHeader = '標準表單登入'; // 插入表單的標題文字
const hideIcon = '--'; // 插入表單的「暫時隱藏」按鈕文字
const closeIcon = '✖'; // 插入表單的「關閉」按鈕文字
const messagePrefix = containerId; // 使用 console.log 時的固定訊息開頭
const formHTML = `
<dialog>
<div class='header'>
<span>${dialogHeader}</span>
<div class='actions'>
<button data-action="hide" type="button">${hideIcon}</button>
<button data-action="close" type="button">${closeIcon}</button>
</div>
</div>
<div class='hint-for-disconnected' style='display: none'>已斷線,請連線後再試</div>
<form method="dialog">
<fieldset>
<label>
代號
<input type="text" name="id" autocomplete="username" required autofocus>
</label>
<label>
密碼
<input type="password" name="password" autocomplete="current-password" required>
</label>
</fieldset>
<button>送出</button>
</form>
</dialog>
`;
const globalStyle = `
:root {
--${containerId}-gray-color: rgba(0, 0, 0, 0.6);
}
#${containerId} > dialog {
padding: 0 1.5em 2em;
font-size: initial;
overflow: hidden;
background-color: rgba(255, 255, 255, .85);
border: 6px solid var(--${containerId}-gray-color);
border-radius: 15px;
}
#${containerId} div.header {
margin: 0.7em 0 1.9em;
display: flex;
justify-content: space-between;
color: var(--${containerId}-gray-color);
opacity: 0.6;
}
#${containerId} div.actions {
margin-left: auto;
}
#${containerId} div.actions button {
border: none;
background: none;
opacity: 0.6;
}
#${containerId} fieldset {
display: inline-block;
}
#${containerId} label {
margin-right: 8px;
font-weight: normal;
}
#${containerId} label > input {
margin: 0 4px;
}
#${containerId} .hint-for-disconnected {
padding: 0.5em;
margin: -0.8em 1em 1.2em;
font-size: larger;
font-weight: bold;
text-align: center;
background-color: black;
color: red;
}
`;
const stopPropagation = e => e.stopPropagation();
const findQuestion = () => {
// 預期登入頁會出現的 HTML 內容:
// <span class="q7 b0">請輸入代號,或以 guest 參觀,或以 new 註冊: </span>
let xpath = `//span[@class='${loginQuestionClass}' and text() = '${loginQuestionText}']`;
let result = document.evaluate(xpath, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
return result.snapshotLength === 1;
};
const waitLoginPage = function (interval, timeout) {
return new Promise((resolve, reject) => {
let startTime = Date.now();
const id = setInterval(() => {
if (findQuestion()) {
clearInterval(id);
resolve();
}
const elapsedTime = Date.now() - startTime;
if (elapsedTime >= timeout) {
clearInterval(id);
reject();
}
}, interval);
});
};
const sendEnter = function (input) {
input.dispatchEvent(new KeyboardEvent('keydown', {key: 'Enter', keyCode: 13, which: 13, bubbles: true}));
};
const doLogin = function (id, password) {
let $t = document.getElementById('t');
$t.value = id;
$t.dispatchEvent(new Event('input'));
sendEnter($t);
$t.value = password;
$t.dispatchEvent(new Event('input'));
sendEnter($t);
};
const hasDisconnected = () => {
const topAlert = document.querySelector('#reactAlert');
return (topAlert && topAlert.querySelector('h4')?.textContent === disconnAlertText);
};
const hintForDisconnected = ($dialog) => {
$dialog.querySelector('.hint-for-disconnected').style.display = 'block';
};
const insertLoginForm = function () {
const $div = document.createElement('div');
$div.innerHTML = formHTML;
$div.id = containerId;
document.body.appendChild($div);
const $dialog = $div.querySelector('dialog');
// 避免事件傳遞到頂層 PttChrome 的 handler,焦點管理會錯亂
['keydown', 'keyup', 'keypress'].forEach(eventName => {
$div.addEventListener(eventName, stopPropagation);
});
$dialog.addEventListener('close', destroy);
// Submit 按鈕
$div.querySelector('form').addEventListener('submit', e => {
if (hasDisconnected()) {
hintForDisconnected($dialog);
e.preventDefault();
return;
}
let data = new FormData(e.target);
if (needPasswordCredential()) {
if ('PasswordCredential' in window) {
const cred = {
id: data.get('id'),
password: data.get('password')
};
navigator.credentials.store(new PasswordCredential(cred));
} else {
log('瀏覽器不支援 PasswordCredential,可能無法記憶密碼');
}
}
doLogin(data.get('id'), data.get('password'));
destroy();
});
// Close 按鈕
$div.querySelector('button[data-action="close"]').addEventListener('click', () => {
$dialog.close();
});
// Hide 按鈕
const hideBtn = $div.querySelector('button[data-action="hide"]');
hideBtn.addEventListener('mousedown', () => { $dialog.style.opacity = 0.05; });
['mouseup', 'mouseout'].forEach(eventName => {
hideBtn.addEventListener(eventName, () => { $dialog.style.opacity = 1; });
});
return $dialog;
};
const insertStyle = function (css) {
const head = document.querySelector('head');
const style = document.createElement('style');
style.type = 'text/css';
style.innerHTML = css;
head.appendChild(style);
};
const log = function (...args) {
console.log(`[${messagePrefix}]`, ...args);
};
const needPasswordCredential = function () {
// Chrome 不會自動偵測到登入,所以「需要」用 PasswordCredential 要求儲存;
// Firefox 目前 (126.0.1) 未支援 PasswordCredential,未來也許能統一作法
if (GM_info?.platform?.name === 'firefox' || navigator.userAgent.includes('Firefox')) {
return false;
}
return true;
};
const destroy = function () {
document.getElementById(containerId).remove();
};
const onInit = function () {
waitLoginPage(500, findQuestionTimeout).then(() => {
insertStyle(globalStyle);
let dialog = insertLoginForm();
dialog.showModal();
}).catch(() => {
log('找不到「請輸入代號...」文字,放棄登入');
});
};
onInit();