// ==UserScript== // @name iKuuu 多域名自动签到 // @namespace https://scriptcat.org/ikuuu-auto-checkin // @version 1.0.0 // @description ScriptCat 定时自动签到 iKuuu,支持多域名切换、自动登录、失败重试和签到结果通知 // @author Codex // @license MIT // @crontab 13 */2 * * * // @grant GM_xmlhttpRequest // @grant GM_notification // @grant GM_openInTab // @grant GM_getValue // @grant GM_setValue // @grant GM_log // @grant GM_registerMenuCommand // @connect ikuuu.nl // @connect ikuuu.win // @connect ikuuu.org // @connect ikuuu.fyi // @connect ikuuu.one // @connect ikuuu.de // @connect ikuuu.eu // @connect ikuuu.pw // @connect ikuuu.ltd // @connect * // @storageName ikuuu_auto_checkin // ==/UserScript== /* ==UserConfig== account: autoLogin: title: 自动登录 description: 未登录时使用下方账号密码登录。账号密码只会发送到 ikuuu.* 域名。 type: checkbox default: true email: title: 登录邮箱 type: text default: "" password: title: 登录密码 type: text password: true default: "" site: extraDomains: title: 额外域名 description: 每行一个域名,不需要写 https://;会排在内置域名前面。 type: textarea default: "" retryAfterMinutes: title: 失败后重试 description: 所有域名失败后,交给 ScriptCat 延迟重试的分钟数。 type: number default: 30 min: 5 max: 360 unit: 分钟 notify: notifyAlreadyChecked: title: 已签到也通知 type: checkbox default: false notifySkipped: title: 当天已成功后跳过也通知 type: checkbox default: false ==/UserConfig== */ const DEFAULT_DOMAINS = [ "ikuuu.win", "ikuuu.nl", "ikuuu.org", "ikuuu.fyi", "ikuuu.one", "ikuuu.de", "ikuuu.eu", "ikuuu.pw", "ikuuu.ltd", ]; const STORE = { lastGoodDomain: "state.lastGoodDomain", lastSuccessDate: "state.lastSuccessDate", lastSuccessMessage: "state.lastSuccessMessage", lock: "runtime.lock", }; const REQUEST_TIMEOUT = 15000; const DOMAIN_ROUNDS = 2; const LOCK_MAX_AGE = 10 * 60 * 1000; GM_registerMenuCommand("iKuuu 立即签到", () => { main({ force: true, manual: true }).catch((error) => { notify("iKuuu 签到失败", getErrorMessage(error), { highlight: true, timeout: 12000 }); }); }, { id: "ikuuu-run-now" }); GM_registerMenuCommand("iKuuu 打开上次可用域名", () => { const domain = GM_getValue(STORE.lastGoodDomain, DEFAULT_DOMAINS[0]); GM_openInTab(buildUrl(domain, "/user"), { active: true }); }, { id: "ikuuu-open-last-domain" }); return main(); async function main(options = {}) { const today = getLocalDateKey(); const lastSuccessDate = GM_getValue(STORE.lastSuccessDate, ""); const notifySkipped = getBooleanConfig("notify.notifySkipped", false); if (!options.force && lastSuccessDate === today) { const message = GM_getValue(STORE.lastSuccessMessage, "今天已经签到成功,本次跳过。"); GM_log("[iKuuu] " + message, "info"); if (notifySkipped) { notify("iKuuu 今日已完成", message, { timeout: 5000 }); } return message; } if (!await acquireLock()) { const message = "已有一次签到正在运行,本次跳过。"; GM_log("[iKuuu] " + message, "warn"); return message; } try { const result = await runCheckin(today, options); return result.message; } catch (error) { const message = getErrorMessage(error); notify("iKuuu 签到失败", message, { highlight: true, timeout: 15000, onclick: error && error.loginUrl ? () => GM_openInTab(error.loginUrl, { active: true }) : undefined, }); if (error && error.retryable) { throw createRetryError(message, getRetryAfterSeconds()); } throw error; } finally { await setStored(STORE.lock, null); } } async function runCheckin(today, options) { const domains = getDomainCandidates(); const errors = []; let loginNeededCount = 0; let loginFailedCount = 0; let discoveredAny = false; for (let round = 1; round <= DOMAIN_ROUNDS; round += 1) { GM_log(`[iKuuu] 开始第 ${round}/${DOMAIN_ROUNDS} 轮域名尝试:${domains.join(", ")}`, "info"); for (let index = 0; index < domains.length; index += 1) { const domain = domains[index]; try { const loginState = await ensureLoggedIn(domain); discoveredAny = discoveredAny || loginState.discoveredDomains.length > 0; appendDiscoveredDomains(domains, loginState.discoveredDomains); const checkin = await postCheckin(domain); await setStored(STORE.lastGoodDomain, domain); await setStored(STORE.lastSuccessDate, today); await setStored(STORE.lastSuccessMessage, checkin.message); if (checkin.already) { GM_log(`[iKuuu] ${domain} 今日已签到:${checkin.message}`, "info"); if (options.manual || getBooleanConfig("notify.notifyAlreadyChecked", false)) { notify("iKuuu 今日已签到", `${domain}: ${checkin.message}`, { timeout: 8000 }); } } else { notify("iKuuu 签到成功", `${domain}: ${checkin.message}`, { highlight: true, timeout: 9000 }); } return { domain, message: checkin.already ? `今日已签到:${checkin.message}` : `签到成功:${checkin.message}`, }; } catch (error) { if (error && error.code === "LOGIN_NEEDED") { loginNeededCount += 1; } if (error && error.code === "LOGIN_FAILED") { loginFailedCount += 1; } const message = `${domain}: ${getErrorMessage(error)}`; errors.push(message); GM_log("[iKuuu] " + message, "warn"); await sleep(1200 + Math.floor(Math.random() * 1600)); } } } const allLoginNeeded = loginNeededCount > 0 && loginNeededCount === errors.length; const allLoginFailed = loginFailedCount > 0 && loginFailedCount === errors.length; const allAuthBlocked = allLoginNeeded || allLoginFailed; const summary = allLoginNeeded ? "所有可访问域名都未登录。请在脚本配置里填写账号密码,或手动登录一个 iKuuu 域名后再运行。" : allLoginFailed ? "自动登录失败。请检查账号密码,或确认站点是否启用了验证码。" : compactErrors(errors, discoveredAny); const finalError = new Error(summary); finalError.retryable = !allAuthBlocked; finalError.errors = errors; if (allLoginNeeded) { finalError.code = "LOGIN_NEEDED"; finalError.loginUrl = buildUrl(domains[0] || DEFAULT_DOMAINS[0], "/auth/login"); } throw finalError; } async function ensureLoggedIn(domain) { const response = await request({ method: "GET", url: buildUrl(domain, "/user"), headers: { "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Cache-Control": "no-cache", }, }); const text = getResponseText(response); const discoveredDomains = discoverDomains(text); if (isLoggedInUserPage(response, text)) { return { loggedIn: true, discoveredDomains }; } if (isLoginPage(response, text)) { await login(domain); return { loggedIn: true, discoveredDomains }; } if (isDeprecatedDomainPage(text)) { throw new Error("域名已迁移,已尝试从页面发现新域名"); } throw new Error(`登录状态未知,HTTP ${response.status || "unknown"},最终地址 ${response.finalUrl || "unknown"}`); } async function login(domain) { const autoLogin = getBooleanConfig("account.autoLogin", true); const email = String(GM_getValue("account.email", "") || "").trim(); const password = String(GM_getValue("account.password", "") || ""); if (!autoLogin || !email || !password) { const error = new Error("未登录,且没有可用的自动登录配置"); error.code = "LOGIN_NEEDED"; error.loginUrl = buildUrl(domain, "/auth/login"); throw error; } if (!isSafeIkuuuHost(domain)) { throw new Error(`为避免泄露密码,脚本拒绝向非 ikuuu.* 域名自动登录:${domain}`); } GM_log(`[iKuuu] ${domain} 未登录,尝试自动登录`, "info"); const data = formEncode({ email, passwd: password, remember_me: "week", }); const response = await request({ method: "POST", url: buildUrl(domain, "/auth/login"), headers: { "Accept": "application/json, text/javascript, */*; q=0.01", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Origin": buildOrigin(domain), "Referer": buildUrl(domain, "/auth/login"), "X-Requested-With": "XMLHttpRequest", }, data, }); const json = parseJson(response); if (json && isTruthyRet(json.ret)) { GM_log(`[iKuuu] ${domain} 自动登录成功`, "info"); return true; } if (isLoggedInUserPage(response, getResponseText(response))) { GM_log(`[iKuuu] ${domain} 自动登录成功`, "info"); return true; } const message = json && json.msg ? json.msg : `登录失败,HTTP ${response.status || "unknown"}`; const error = new Error(message); error.code = "LOGIN_FAILED"; throw error; } async function postCheckin(domain) { const response = await request({ method: "POST", url: buildUrl(domain, "/user/checkin"), headers: { "Accept": "application/json, text/javascript, */*; q=0.01", "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", "Origin": buildOrigin(domain), "Referer": buildUrl(domain, "/user"), "X-Requested-With": "XMLHttpRequest", }, data: "", }); const json = parseJson(response); const text = getResponseText(response); if (!json) { if (isLoginPage(response, text)) { const error = new Error("签到接口返回登录页,Cookie 可能已失效"); error.code = "LOGIN_NEEDED"; throw error; } throw new Error(`签到响应不是 JSON,HTTP ${response.status || "unknown"}`); } const message = String(json.msg || json.message || JSON.stringify(json)); const ret = json.ret; if (response.status === 200 && isTruthyRet(ret)) { return { ok: true, already: false, message }; } if (response.status === 200 && isAlreadyChecked(message)) { return { ok: true, already: true, message }; } if (/未登录|登录|login/i.test(message)) { const error = new Error(message); error.code = "LOGIN_NEEDED"; throw error; } throw new Error(`签到失败:${message}`); } function request(details) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ timeout: REQUEST_TIMEOUT, responseType: "text", ...details, onload: resolve, onerror: (error) => reject(new Error("请求错误" + (error ? `:${error}` : ""))), ontimeout: () => reject(new Error("请求超时")), onabort: () => reject(new Error("请求被中止")), }); }); } async function acquireLock() { const lock = GM_getValue(STORE.lock, null); const now = Date.now(); if (lock && lock.startedAt && now - lock.startedAt < LOCK_MAX_AGE) { return false; } await setStored(STORE.lock, { startedAt: now }); return true; } async function setStored(key, value) { if (typeof GM !== "undefined" && GM && typeof GM.setValue === "function") { await GM.setValue(key, value); return; } GM_setValue(key, value); } function getDomainCandidates() { const extraDomains = splitDomains(GM_getValue("site.extraDomains", "")); const lastGoodDomain = normalizeDomain(GM_getValue(STORE.lastGoodDomain, "")); return uniqueDomains([lastGoodDomain, ...extraDomains, ...DEFAULT_DOMAINS]); } function splitDomains(value) { return String(value || "") .split(/[\n,,\s]+/) .map(normalizeDomain) .filter(Boolean); } function normalizeDomain(value) { return String(value || "") .trim() .replace(/^https?:\/\//i, "") .replace(/\/.*$/, "") .replace(/^www\./i, "") .toLowerCase(); } function uniqueDomains(domains) { const seen = new Set(); const result = []; for (const domain of domains) { const normalized = normalizeDomain(domain); if (!normalized || seen.has(normalized)) continue; seen.add(normalized); result.push(normalized); } return result; } function appendDiscoveredDomains(domains, discoveredDomains) { for (const domain of discoveredDomains) { const normalized = normalizeDomain(domain); if (normalized && !domains.includes(normalized)) { domains.push(normalized); GM_log(`[iKuuu] 发现新域名:${normalized}`, "info"); } } } function discoverDomains(text) { const found = []; const source = String(text || ""); const domainPattern = /\b(?:https?:\/\/)?(ikuuu\.[a-z0-9.-]{2,})\b/gi; let match; while ((match = domainPattern.exec(source))) { found.push(match[1]); } return uniqueDomains(found); } function buildOrigin(domain) { return `https://${normalizeDomain(domain)}`; } function buildUrl(domain, path) { const cleanPath = path.startsWith("/") ? path : `/${path}`; return `${buildOrigin(domain)}${cleanPath}`; } function getResponseText(response) { if (!response) return ""; if (typeof response.responseText === "string") return response.responseText; if (typeof response.response === "string") return response.response; return ""; } function parseJson(response) { if (!response) return null; if (response.response && typeof response.response === "object") { return response.response; } const text = getResponseText(response).trim(); if (!text) return null; try { return JSON.parse(text); } catch (error) { return null; } } function isLoggedInUserPage(response, text) { const finalUrl = String(response && response.finalUrl || ""); if (/\/user\/?$/i.test(finalUrl) || /\/user[?#]/i.test(finalUrl)) return true; return /\/user\/checkin/i.test(text) || /用户中心|会员中心|剩余流量|今日已用|签到/i.test(text); } function isLoginPage(response, text) { const finalUrl = String(response && response.finalUrl || ""); return /\/auth\/login/i.test(finalUrl) || /name=["']email["']/i.test(text) && /name=["']passwd["']/i.test(text) || /用户登录|登录/i.test(text) && /auth\/login/i.test(text); } function isDeprecatedDomainPage(text) { return /域名已更改|Domain deprecated|最新域名|redirected to our new domain/i.test(String(text || "")); } function isAlreadyChecked(message) { return /已签到|已经签到|签到过|今日已|重复签到|似乎已经/i.test(String(message || "")); } function isTruthyRet(value) { return value === true || value === 1 || value === "1" || value === "true"; } function isSafeIkuuuHost(domain) { return /^ikuuu\.[a-z0-9.-]+$/i.test(normalizeDomain(domain)); } function formEncode(data) { return Object.keys(data) .map((key) => `${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`) .join("&"); } function getLocalDateKey() { const date = new Date(); const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function getBooleanConfig(key, defaultValue) { const value = GM_getValue(key, defaultValue); if (typeof value === "boolean") return value; if (typeof value === "string") return !/^(false|0|no|off)$/i.test(value); return Boolean(value); } function getRetryAfterSeconds() { const minutes = Number(GM_getValue("site.retryAfterMinutes", 30)); const safeMinutes = Number.isFinite(minutes) ? Math.min(Math.max(minutes, 5), 360) : 30; return Math.floor(safeMinutes * 60); } function createRetryError(message, seconds) { if (typeof CATRetryError !== "undefined") { return new CATRetryError(message, seconds); } const error = new Error(message); error.retryAfterSeconds = seconds; return error; } function compactErrors(errors, discoveredAny) { const prefix = discoveredAny ? "已尝试内置域名和页面发现的新域名,但仍未签到成功。" : "所有内置域名都未签到成功。"; const tail = errors.slice(-4).join(";"); return `${prefix}${tail ? " 最近错误:" + tail : ""}`; } function getErrorMessage(error) { if (!error) return "未知错误"; if (typeof error === "string") return error; return error.message || String(error); } function notify(title, text, options = {}) { const details = { title, text: String(text || "").slice(0, 280), timeout: options.timeout || 8000, highlight: Boolean(options.highlight), }; if (options.onclick) { details.onclick = options.onclick; } GM_notification(details); } function sleep(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)); }