Greasy Fork is available in English.

PTT 半自動登入

在登入畫面顯示獨立的密碼表單,以沿用瀏覽器內建密碼功能登入

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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();