Skip to content

Instantly share code, notes, and snippets.

@zhasm
Last active January 22, 2026 12:02
Show Gist options
  • Select an option

  • Save zhasm/e17e15429260f0d341503a8e8253ad1c to your computer and use it in GitHub Desktop.

Select an option

Save zhasm/e17e15429260f0d341503a8e8253ad1c to your computer and use it in GitHub Desktop.
fanfou user switch
// ==UserScript==
// @name SpaceFanfou light
// @namespace http://tampermonkey.net/
// @version 1.1.3
// @description Switch between saved Fanfou accounts with a dropdown menu and background login dialog
// @author AutoGenerated
// @match https://*.fanfou.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @run-at document-end
// ==/UserScript==
// logs
// 1.1.0 switch user;
// 1.1.1 add paste image from clipboard feature
// 1.1.2 add image URL cleaner feature (removes @suffix from ZoomBox images)
// 1.1.3 fix cookie extension logic
(function () {
'use strict';
const STORAGE_KEY = 'switch_user_allUserData';
const COOKIE_DOMAIN = '.fanfou.com';
const CSS = `
#user_top.sf-is-ready {
position: absolute;
width: 202px;
/* 这个z-index在弹出的图片中,显示异常,暂时注释掉 */
/* z-index: 1000; */
border-radius: 3px;
margin: -5px 0 0 -5px;
padding: 5px;
transition: background-color 0.2s, box-shadow 0.2s;
}
#user_top.sf-is-ready:hover {
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
background-color: #fff;
}
#user_top.sf-is-ready + #reminder {
margin-top: 47px;
}
#user_top > a img {
width: 32px;
height: 32px;
margin-right: 10px;
}
#user_top > h3::after {
content: "\\25BE";
margin-left: 7px;
opacity: 0.5;
}
#sf-user-switcher {
display: none;
position: relative;
margin-top: 6px;
border-top: 1px solid #e5e5e5;
font-size: 12px;
line-height: 24px;
padding-top: 5px;
}
#user_top.sf-is-ready:hover #sf-user-switcher {
display: block;
}
#sf-user-switcher .sf-user-item {
display: table;
width: 100%;
padding: 2px 0;
transition: background-color 0.1s;
}
#sf-user-switcher .sf-user-item:hover {
background-color: rgba(0, 0, 0, 0.025);
}
#sf-user-switcher .sf-user-info,
#sf-user-switcher .sf-del-icon {
display: table-cell;
vertical-align: middle;
}
#sf-user-switcher .sf-user-info {
width: 100%;
height: auto;
cursor: pointer;
color: #333;
text-decoration: none;
}
#sf-user-switcher .sf-user-info img {
float: left;
width: 16px;
height: 16px;
margin: 4px 5px;
border-radius: 2px;
}
#sf-user-switcher .sf-del-icon {
width: 20px;
text-align: center;
cursor: pointer;
color: #ccc;
font-size: 14px;
visibility: hidden;
}
#sf-user-switcher .sf-user-item:hover .sf-del-icon {
visibility: visible;
}
#sf-user-switcher .sf-del-icon:hover {
color: #c00;
}
#sf-user-switcher .sf-add-new-user {
padding: 8px 0 4px;
border-top: 1px solid #e5e5e5;
text-align: center;
}
#sf-user-switcher .formbutton {
letter-spacing: 0;
padding: 3px 10px;
cursor: pointer;
background: #f0f0f0;
border: 1px solid #ccc;
border-radius: 3px;
font-size: 12px;
}
#sf-user-switcher .formbutton:hover {
background: #e5e5e5;
}
/* Login Dialog Styles */
#sf-login-dialog-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 10001;
display: flex;
align-items: center;
justify-content: center;
}
#sf-login-dialog {
background: #fff;
padding: 20px;
border-radius: 5px;
width: 300px;
box-shadow: 0 4px 12px rgba(0,0,0,0.2);
}
#sf-login-dialog h2 {
margin: 0 0 15px;
font-size: 18px;
text-align: center;
}
#sf-login-dialog .field {
margin-bottom: 10px;
}
#sf-login-dialog label {
display: block;
margin-bottom: 5px;
font-size: 12px;
}
#sf-login-dialog input[type="text"],
#sf-login-dialog input[type="password"] {
width: 100%;
padding: 6px;
box-sizing: border-box;
border: 1px solid #ccc;
border-radius: 3px;
}
#sf-login-dialog .buttons {
margin-top: 15px;
text-align: right;
}
#sf-login-dialog .buttons button {
padding: 5px 15px;
margin-left: 10px;
cursor: pointer;
}
#sf-login-dialog .error {
color: #c00;
font-size: 12px;
margin-bottom: 10px;
text-align: center;
}
#sf-login-dialog .captcha-container {
text-align: center;
margin-bottom: 10px;
}
#sf-login-dialog .captcha-container img {
cursor: pointer;
max-width: 100%;
}
/* Hide specific page elements */
#goodapp {
display: none !important;
}
#reminder li a {
display: none !important;
}
`;
GM_addStyle(CSS);
// Cookie helpers
function getCookies() {
return document.cookie.split(';').reduce((obj, pair) => {
const [k, v] = pair.trim().split('=');
if (k) obj[k] = v;
return obj;
}, {});
}
function setCookie(k, v, expiresDays = 30) {
const expires = new Date();
expires.setTime(expires.getTime() + expiresDays * 24 * 60 * 60 * 1000);
document.cookie = `${k}=${v};domain=${COOKIE_DOMAIN};expires=${expires.toUTCString()};path=/`;
}
function deleteCookie(k) {
document.cookie = `${k}=;domain=${COOKIE_DOMAIN};expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/`;
}
// Storage helpers
function loadAllUserData() {
return GM_getValue(STORAGE_KEY, []);
}
function saveAllUserData(data) {
GM_setValue(STORAGE_KEY, data);
}
function getLoggedInUserId() {
const cookies = getCookies();
return cookies['u'] || '';
}
async function addOrUpdateCurrentUser() {
const userId = getLoggedInUserId();
if (!userId) return;
const nickname = document.querySelector('#user_top h3')?.textContent?.replace(/▾$/, '').trim() || '';
const avatarUrl = document.querySelector('#user_top img')?.src || '';
const allCookies = getCookies();
const filteredCookies = Object.fromEntries(
Object.entries(allCookies).filter(([k]) => !(k.startsWith('_') || k === 'uuid'))
);
const userData = { userId, nickname, avatarUrl, cookies: filteredCookies, lastUpdated: Date.now() };
let allData = loadAllUserData();
allData = allData.filter(u => u.userId !== userId);
allData.unshift(userData);
saveAllUserData(allData);
}
function switchToUser(userId) {
const allData = loadAllUserData();
const user = allData.find(u => u.userId === userId);
if (!user) return;
for (const [k, v] of Object.entries(user.cookies)) {
setCookie(k, v);
}
// Update the lastUpdated timestamp for this user
user.lastUpdated = Date.now();
saveAllUserData(allData);
window.location.href = '/home';
}
function extendExpiringCookies() {
const allData = loadAllUserData();
const now = Date.now();
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const EXPIRATION_THRESHOLD = THIRTY_DAYS_MS - SEVEN_DAYS_MS; // 23 days
let updated = false;
allData.forEach(user => {
// If no lastUpdated timestamp, set it to now (for backward compatibility)
if (!user.lastUpdated) {
user.lastUpdated = now;
updated = true;
return;
}
const age = now - user.lastUpdated;
// If cookies are older than 23 days (will expire in less than 7 days), extend them
if (age > EXPIRATION_THRESHOLD) {
console.log(`Extending cookies for user: ${user.nickname} (age: ${Math.round(age / (24 * 60 * 60 * 1000))} days)`);
// Re-set all cookies with fresh 30-day expiration
for (const [k, v] of Object.entries(user.cookies)) {
setCookie(k, v);
}
user.lastUpdated = now;
updated = true;
}
});
if (updated) {
saveAllUserData(allData);
}
}
function removeUser(userId) {
const allData = loadAllUserData();
const filtered = allData.filter(u => u.userId !== userId);
saveAllUserData(filtered);
renderSwitcher();
}
// UI Rendering
function renderSwitcher() {
const userTop = document.querySelector('#user_top');
if (!userTop) return;
userTop.classList.add('sf-is-ready');
let switcher = document.getElementById('sf-user-switcher');
if (switcher) switcher.remove();
switcher = document.createElement('ul');
switcher.id = 'sf-user-switcher';
const allData = loadAllUserData();
const currentUserId = getLoggedInUserId();
allData.forEach(user => {
if (user.userId === currentUserId) return;
const li = document.createElement('li');
li.className = 'sf-user-item';
const info = document.createElement('a');
info.className = 'sf-user-info';
info.href = 'javascript:void(0)';
info.innerHTML = `<img src="${user.avatarUrl}" alt="">${user.nickname}`;
info.addEventListener('click', () => switchToUser(user.userId));
const del = document.createElement('span');
del.className = 'sf-del-icon';
del.textContent = '×';
del.title = '删除此用户';
del.addEventListener('click', (e) => {
e.stopPropagation();
if (confirm(`确定要从切换列表中删除 @${user.nickname} 吗?`)) {
removeUser(user.userId);
}
});
li.appendChild(info);
li.appendChild(del);
switcher.appendChild(li);
});
const addLi = document.createElement('li');
addLi.className = 'sf-add-new-user';
const addBtn = document.createElement('input');
addBtn.type = 'button';
addBtn.value = '登入另一个……';
addBtn.className = 'formbutton';
addBtn.addEventListener('click', showLoginDialog);
addLi.appendChild(addBtn);
switcher.appendChild(addLi);
userTop.appendChild(switcher);
}
// Login Dialog Implementation
let loginDialogToken = '';
function showLoginDialog() {
const overlay = document.createElement('div');
overlay.id = 'sf-login-dialog-overlay';
const dialog = document.createElement('div');
dialog.id = 'sf-login-dialog';
dialog.innerHTML = `
<h2>添加账户</h2>
<div id="sf-login-error" class="error"></div>
<div class="field">
<label>用户名或 Email</label>
<input type="text" id="sf-login-name">
</div>
<div class="field">
<label>密码</label>
<input type="password" id="sf-login-pass">
</div>
<div id="sf-captcha-container" class="captcha-container" style="display:none">
<img id="sf-captcha-img" title="点击刷新验证码">
<input type="text" id="sf-captcha-val" placeholder="输入验证码">
</div>
<div class="buttons">
<button id="sf-login-cancel">取消</button>
<button id="sf-login-submit" style="background:#007bff;color:#fff;border:none;border-radius:3px;">登录</button>
</div>
`;
overlay.appendChild(dialog);
document.body.appendChild(overlay);
const errorEl = dialog.querySelector('#sf-login-error');
const captchaContainer = dialog.querySelector('#sf-captcha-container');
const captchaImg = dialog.querySelector('#sf-captcha-img');
const updateCaptcha = () => {
captchaImg.src = `https://fanfou.com/captcha.png?${Date.now()}`;
captchaContainer.style.display = 'block';
};
captchaImg.onclick = updateCaptcha;
// Get CSRF Token from login page
GM_xmlhttpRequest({
method: "GET",
url: "https://fanfou.com/login",
onload: function (res) {
const doc = new DOMParser().parseFromString(res.responseText, "text/html");
loginDialogToken = doc.querySelector('input[name="token"]')?.value || '';
if (res.responseText.includes('captcha')) {
updateCaptcha();
}
}
});
dialog.querySelector('#sf-login-cancel').onclick = () => overlay.remove();
dialog.querySelector('#sf-login-submit').onclick = async () => {
const loginname = dialog.querySelector('#sf-login-name').value;
const loginpass = dialog.querySelector('#sf-login-pass').value;
const captcha = dialog.querySelector('#sf-captcha-val').value;
if (!loginname || !loginpass) {
errorEl.textContent = '请输入用户名和密码';
return;
}
errorEl.textContent = '登录中...';
// Backup current cookies
const originalCookies = getCookies();
// Perform Login POST
const formData = new URLSearchParams();
formData.append('loginname', loginname);
formData.append('loginpass', loginpass);
formData.append('action', 'login');
formData.append('token', loginDialogToken);
if (captcha) formData.append('captcha', captcha);
formData.append('auto_login', 'on');
GM_xmlhttpRequest({
method: "POST",
url: "https://fanfou.com/login",
data: formData.toString(),
headers: { "Content-Type": "application/x-www-form-urlencoded" },
onload: function (res) {
// Check if login was successful by checking the redirected page or Set-Cookie
// GM_xmlhttpRequest might not give us the result of Set-Cookie in a helpful way,
// but if it worked, the document.cookie on fanfou.com will have changed.
// We wait a bit to ensure browser has processed cookies from the request
setTimeout(async () => {
const newCookies = getCookies();
const newUserId = newCookies['u'];
if (newUserId && newUserId !== originalCookies['u']) {
// Success! Add to switcher
const userData = {
userId: newUserId,
nickname: loginname, // Fallback, will be updated when user actually switches
avatarUrl: 'https://fanfou.com/img/default_avatar_32.png', // Temporary
cookies: Object.fromEntries(
Object.entries(newCookies).filter(([k]) => !(k.startsWith('_') || k === 'uuid'))
)
};
// Fetch profile to get real nickname/avatar
GM_xmlhttpRequest({
method: "GET",
url: "https://fanfou.com/home",
onload: function (profileRes) {
const pDoc = new DOMParser().parseFromString(profileRes.responseText, "text/html");
userData.nickname = pDoc.querySelector('#user_top h3')?.textContent?.replace(/▾$/, '').trim() || userData.nickname;
userData.avatarUrl = pDoc.querySelector('#user_top img')?.src || userData.avatarUrl;
let allData = loadAllUserData();
allData = allData.filter(u => u.userId !== userData.userId);
allData.unshift(userData);
saveAllUserData(allData);
// Restore original cookies
restoreCookies(originalCookies);
overlay.remove();
renderSwitcher();
}
});
} else {
errorEl.textContent = '登录失败,请检查账号密码或验证码';
// If captcha was needed but wrong, refresh it
updateCaptcha();
// Restore original cookies just in case
restoreCookies(originalCookies);
}
}, 1000);
}
});
};
}
function restoreCookies(cookies) {
// Clear current cookies first to be safe
const current = getCookies();
for (const k in current) {
deleteCookie(k);
}
// Set backup cookies
for (const [k, v] of Object.entries(cookies)) {
setCookie(k, v);
}
}
// ====================================
// 图片 URL 清理功能
// ====================================
function initImageUrlCleaner() {
/**
* 功能说明:
* 1. 监视 #ZoomBox 中的图片元素
* 2. 自动移除图片 URL 中的 @suffix 部分(如 @596w_1l.jpg)
* 3. 使用 MutationObserver 实时监控 DOM 变化
*/
// 辅助函数:移除 URL 中的 @suffix
function stripAtSuffix(url) {
if (!url) return url;
// 在最后一个 '/' 之后查找 '@',如果存在则移除 @ 及其后面的内容
// 示例:.../image.jpg@596w_1l.jpg -> .../image.jpg
const lastSlash = url.lastIndexOf('/');
const atPos = url.indexOf('@', lastSlash + 1);
if (atPos === -1) return url;
return url.slice(0, atPos);
}
// 处理单个图片元素
function processImage(img) {
if (!img || !img.src) return;
const newSrc = stripAtSuffix(img.src);
if (newSrc !== img.src) {
img.src = newSrc;
// 移除可能冲突的 srcset 属性
if (img.getAttribute('srcset')) {
img.removeAttribute('srcset');
}
// 处理常见的 lazy loading 属性
const lazyAttrs = ['data-src', 'data-original', 'data-lazy', 'data-srcset'];
lazyAttrs.forEach(attr => {
const value = img.getAttribute(attr);
if (value) {
img.setAttribute(attr, stripAtSuffix(value));
}
});
console.log('Space Fanfou Image URL Cleaner: Cleaned image URL');
}
}
// 查找并处理目标图片
function processZoomBoxImage() {
const img = document.querySelector('#ZoomBox img');
if (img) {
processImage(img);
}
}
// 创建 MutationObserver 监视 DOM 变化
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
// 处理新添加的节点
if (mutation.addedNodes && mutation.addedNodes.length) {
for (const node of mutation.addedNodes) {
if (!(node instanceof HTMLElement)) continue;
// 检查是否为 #ZoomBox 元素
if (node.matches && node.matches('#ZoomBox')) {
const img = node.querySelector('img');
if (img) processImage(img);
} else {
// 检查子元素中是否有 #ZoomBox img
const img = node.querySelector && node.querySelector('#ZoomBox img');
if (img) processImage(img);
// 检查节点本身是否为目标图片
if (node.matches && node.matches('#ZoomBox img')) {
processImage(node);
}
}
}
}
// 处理属性变化
if (mutation.type === 'attributes' && mutation.target) {
const target = mutation.target;
if (target instanceof HTMLImageElement && target.closest && target.closest('#ZoomBox')) {
if (mutation.attributeName === 'src' || mutation.attributeName === 'srcset') {
processImage(target);
}
}
}
}
});
// 开始观察 DOM
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['src', 'srcset']
});
// 页面加载完成后立即处理一次
processZoomBoxImage();
console.log('Space Fanfou Image URL Cleaner: Feature initialized');
}
// ====================================
// 粘贴图片功能
// ====================================
function initPasteImageFeature() {
/**
* 功能说明:
* 1. 监听窗口的 paste 事件
* 2. 从剪贴板获取图片数据
* 3. 将图片转换为 base64 格式
* 4. 修改表单参数,准备上传图片
*/
// 辅助函数:检查文件类型是否为图片
function isImage(type) {
return /^image\/(jpe?g|png|gif|bmp)$/i.test(type);
}
// 辅助函数:将 Blob 转换为 base64
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
});
}
// 辅助函数:显示元素
function showElement(element) {
if (!element) return;
if (element.style.display === 'none') {
element.style.display = '';
}
const display = window.getComputedStyle(element).display;
if (display === 'none') {
element.style.display = 'inline';
}
}
// 粘贴事件处理函数
async function onPaste(event) {
// 获取剪贴板项目
const items = Array.from(event.clipboardData.items);
// 将项目转换为文件,过滤出 Blob 对象
const files = items.map(item => item.getAsFile()).filter(x => x instanceof Blob);
// 找出第一个图片文件
const imageBlob = files.find(file => isImage(file.type));
// 如果没有图片,直接返回
if (!imageBlob) return;
// 提取图片类型(如 jpeg, png 等)
const imageType = imageBlob.type.replace('image/', '');
// 查找页面上的关键元素
const uploadFilename = document.querySelector('#upload-filename');
const closeHandle = document.querySelector('#ul_close');
const messageForm = document.querySelector('#message');
const actionField = document.querySelector('#phupdate input[name="action"]');
const textarea = document.querySelector('#phupdate textarea');
const base64Input = document.querySelector('#upload-base64');
const uploadWrapper = document.querySelector('#upload-wrapper');
// 如果关键元素不存在,说明不在正确的页面,直接返回
if (!uploadFilename || !messageForm || !actionField || !textarea || !base64Input) {
console.log('Space Fanfou Paste Image: Required elements not found');
return;
}
// 设置上传的文件名
uploadFilename.textContent = `image-from-clipboard.${imageType}`;
showElement(uploadFilename);
// 显示关闭按钮
if (closeHandle) {
showElement(closeHandle);
}
// 修改表单属性,准备上传图片
messageForm.setAttribute('action', '/home/upload');
messageForm.setAttribute('enctype', 'multipart/form-data');
// 修改 action 字段的值
actionField.value = 'photo.upload';
// 修改 textarea 的 name 属性
textarea.setAttribute('name', 'desc');
// 将图片转换为 base64 并设置到隐藏字段
try {
const base64Data = await blobToBase64(imageBlob);
base64Input.value = base64Data;
// 显示上传区域
if (uploadWrapper) {
showElement(uploadWrapper);
}
console.log('Space Fanfou Paste Image: Image pasted successfully');
} catch (error) {
console.error('Space Fanfou Paste Image: Failed to convert image to base64', error);
}
}
// 等待页面加载完成后,检查是否有发消息的文本框
function checkAndAttachListener() {
const textarea = document.querySelector('#phupdate textarea');
if (textarea) {
// 添加粘贴事件监听器
window.addEventListener('paste', onPaste);
console.log('Space Fanfou Paste Image: Feature initialized');
} else {
console.log('Space Fanfou Paste Image: Textarea not found, feature not initialized');
}
}
// 页面加载完成后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', checkAndAttachListener);
} else {
checkAndAttachListener();
}
}
function init() {
// Extend cookies for users that will expire within 7 days
extendExpiringCookies();
addOrUpdateCurrentUser();
renderSwitcher();
// Hook logout
const logoutLink = Array.from(document.querySelectorAll('#navigation a')).find(a => a.textContent.includes('退出'));
if (logoutLink) {
logoutLink.addEventListener('click', () => {
const uid = getLoggedInUserId();
if (uid) {
const allData = loadAllUserData();
saveAllUserData(allData.filter(u => u.userId !== uid));
}
});
}
}
// 初始化所有功能模块
init();
initPasteImageFeature();
initImageUrlCleaner();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment