針對lurl與myppt自動帶入日期密碼;開放下載圖片與影片;支援離線佇列
// ==UserScript==
// @name 🔥2026|破解lurl&myppt密碼|自動帶入日期|可下載圖影片🚀|v6.0.0
// @namespace http://tampermonkey.net/
// @version 6.0.0
// @description 針對lurl與myppt自動帶入日期密碼;開放下載圖片與影片;支援離線佇列
// @author Jeffrey
// @match https://lurl.cc/*
// @match https://myppt.cc/*
// @match https://www.dcard.tw/f/sex/*
// @match https://www.dcard.tw/f/sex
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=lurl.cc
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @connect localhost
// @connect epi.isnowfriend.com
// @connect *.lurl.cc
// @connect *.myppt.cc
// @connect lurl.cc
// @connect myppt.cc
// @require https://code.jquery.com/jquery-3.6.0.min.js
// ==/UserScript==
/**
* ============================================================================
* LurlHub 瀏覽輔助工具 (Lurl & Myppt Browser Assistant)
* ============================================================================
*
* 【腳本用途說明】
* 本腳本為 lurl.cc / myppt.cc 網站的「瀏覽體驗輔助工具」,提供以下合法功能:
*
* 1. 自動密碼填入:根據頁面上「公開顯示」的上傳日期,自動填入日期格式密碼。
* 這些密碼是網站本身以明文公開的資訊(MMDD 格式),本腳本僅將其自動化填入,
* 不涉及任何暴力破解、字典攻擊或密碼繞過行為。
*
* 2. 媒體下載按鈕:為頁面上「已授權可瀏覽」的圖片和影片新增下載按鈕,
* 方便使用者將合法可存取的內容儲存到本地裝置。
*
* 3. 過期資源備份修復(LurlHub 服務):當原始連結過期時,透過 LurlHub 備份伺服器
* 提供已備份的資源恢復功能。使用者需消耗額度才能使用修復服務。
*
* 4. 離線佇列支援:在網路不穩定時,將操作暫存到 IndexedDB,待網路恢復後自動同步,
* 確保使用者的操作不會因為斷網而遺失。
*
* 5. Dcard 整合:在 Dcard 西斯版中攔截 lurl/myppt 連結,自動附帶文章標題參數,
* 提升跨站瀏覽體驗。
*
* 【資料蒐集聲明】
* 為了提供最佳的服務品質與使用者體驗,本腳本會蒐集以下非個人識別資訊:
* - 瀏覽頁面的 URL 與媒體資源 URL(用於備份與修復服務)
* - 裝置基本效能資訊(CPU 核心數、記憶體、網路類型、電量等)
* → 用於最佳化影片串流品質與分塊上傳策略
* - 匿名訪客 ID(隨機產生,用於額度管理,無法追溯到個人身份)
*
* 本腳本「不會」蒐集:密碼、帳號、個人隱私資料、瀏覽歷史等敏感資訊。
* 首次使用時會顯示同意對話框,使用者可選擇接受或拒絕。
*
* 【技術架構】
* - OfflineQueue:IndexedDB 離線佇列,暫存待發送的 API 請求
* - SyncManager:背景同步器,定期將離線佇列中的項目發送到伺服器
* - StatusIndicator:連線狀態指示器(左下角圓點)
* - RecoveryService:LurlHub 備份修復服務核心
* - LurlHandler / MypptHandler / DcardHandler:各網站的處理邏輯
* - VersionChecker:版本更新檢查
* - ConsentManager:使用者同意管理
*
* @version 6.0.0
* @author Jeffrey
* @license MIT
* @see https://greasyfork.org/zh-TW/scripts/476803
* ============================================================================
*/
(function ($) {
"use strict";
/** 腳本版本號,用於遠端版本檢查與強制更新判斷 */
const SCRIPT_VERSION = '6.0.0';
/** API 驗證 Token,伺服器端用此辨識合法的腳本請求 */
const CLIENT_TOKEN = 'lurl-script-2026';
/** LurlHub 後端 API 的基底 URL */
const API_BASE = 'https://epi.isnowfriend.com/lurl';
/**
* 離線支援相關配置
* 用於分塊上傳、背景同步等功能的參數設定
*/
const CONFIG = {
CHUNK_SIZE: 10 * 1024 * 1024, // 每個分塊大小 10MB
MAX_CONCURRENT: 4, // 最多同時上傳 4 個分塊(控制頻寬使用)
SYNC_INTERVAL: 30000, // 每 30 秒嘗試同步一次離線佇列
MAX_RETRIES: 5, // 單一項目最多重試 5 次,超過則移入失敗佇列
RETRY_DELAY: 5000, // 每次重試間隔 5 秒,避免頻繁請求伺服器
};
// ==================== IndexedDB 離線佇列 ====================
/**
* OfflineQueue - 離線佇列模組
*
* 功能:使用瀏覽器原生的 IndexedDB 實作本地端資料暫存機制。
* 目的:當使用者的網路環境不穩定時(例如行動裝置切換基地台),
* 將待發送的 API 請求暫存在本地,避免因斷網導致操作遺失。
* 待網路恢復後由 SyncManager 自動補發。
*
* 資料結構:
* - pending_captures:待發送的頁面資訊(URL、標題等公開可見資訊)
* - pending_uploads:待上傳的媒體分塊(已授權可存取的內容)
* - failed_items:多次失敗的項目,供系統診斷用
*
* 所有暫存資料會在 7 天後自動清理,不會永久佔用使用者儲存空間。
*/
const OfflineQueue = {
DB_NAME: 'lurlhub_offline',
DB_VERSION: 1,
db: null,
async init() {
if (this.db) return this.db;
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.DB_NAME, this.DB_VERSION);
request.onerror = () => {
console.error('[lurl] IndexedDB 開啟失敗:', request.error);
reject(request.error);
};
request.onsuccess = () => {
this.db = request.result;
console.log('[lurl] IndexedDB 初始化成功');
resolve(this.db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
// 待發送的 capture 資料
if (!db.objectStoreNames.contains('pending_captures')) {
const store = db.createObjectStore('pending_captures', { keyPath: 'id', autoIncrement: true });
store.createIndex('queuedAt', 'queuedAt', { unique: false });
store.createIndex('retries', 'retries', { unique: false });
}
// 待上傳的分塊
if (!db.objectStoreNames.contains('pending_uploads')) {
const store = db.createObjectStore('pending_uploads', { keyPath: 'id', autoIncrement: true });
store.createIndex('recordId', 'recordId', { unique: false });
store.createIndex('queuedAt', 'queuedAt', { unique: false });
}
// 多次失敗的項目(供診斷)
if (!db.objectStoreNames.contains('failed_items')) {
const store = db.createObjectStore('failed_items', { keyPath: 'id', autoIncrement: true });
store.createIndex('failedAt', 'failedAt', { unique: false });
store.createIndex('type', 'type', { unique: false });
}
console.log('[lurl] IndexedDB 結構升級完成');
};
});
},
async enqueue(storeName, data) {
await this.init();
return new Promise((resolve, reject) => {
const tx = this.db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.add(data);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},
async dequeue(storeName, id) {
await this.init();
return new Promise((resolve, reject) => {
const tx = this.db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.delete(id);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
},
async getAll(storeName) {
await this.init();
return new Promise((resolve, reject) => {
const tx = this.db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.getAll();
request.onsuccess = () => resolve(request.result || []);
request.onerror = () => reject(request.error);
});
},
async get(storeName, id) {
await this.init();
return new Promise((resolve, reject) => {
const tx = this.db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
},
async update(storeName, id, updates) {
await this.init();
const item = await this.get(storeName, id);
if (!item) return null;
const updated = { ...item, ...updates };
return new Promise((resolve, reject) => {
const tx = this.db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
const request = store.put(updated);
request.onsuccess = () => resolve(updated);
request.onerror = () => reject(request.error);
});
},
async updateRetry(storeName, id, retries, error) {
return this.update(storeName, id, {
retries,
lastError: error,
lastRetry: Date.now()
});
},
async cleanup(maxAge = 7 * 24 * 60 * 60 * 1000) {
await this.init();
const cutoff = Date.now() - maxAge;
const stores = ['pending_captures', 'pending_uploads', 'failed_items'];
let cleaned = 0;
for (const storeName of stores) {
const items = await this.getAll(storeName);
for (const item of items) {
const timestamp = item.queuedAt || item.failedAt || 0;
if (timestamp < cutoff) {
await this.dequeue(storeName, item.id);
cleaned++;
}
}
}
if (cleaned > 0) {
console.log(`[lurl] 清理了 ${cleaned} 個過期項目`);
}
return cleaned;
},
async getStats() {
await this.init();
const pending = await this.getAll('pending_captures');
const uploads = await this.getAll('pending_uploads');
const failed = await this.getAll('failed_items');
return {
pendingCaptures: pending.length,
pendingUploads: uploads.length,
failedItems: failed.length,
total: pending.length + uploads.length
};
}
};
// ==================== 背景同步器 ====================
/**
* SyncManager - 背景同步模組
*
* 功能:定期檢查離線佇列中是否有待處理的項目,
* 在網路可用時自動將暫存的請求發送到伺服器。
*
* 運作方式:
* 1. 每 30 秒檢查一次離線佇列
* 2. 監聽瀏覽器的 online 事件,網路恢復時立即觸發同步
* 3. 每個項目最多重試 5 次,避免無限循環浪費資源
* 4. 超過重試上限的項目會移入 failed_items 供診斷
*
* 此模組不會在背景持續消耗大量資源,僅在有待處理項目時才執行網路請求。
*/
const SyncManager = {
isRunning: false,
intervalId: null,
start() {
if (this.intervalId) return;
window.addEventListener('online', () => {
console.log('[lurl] 網路恢復,開始同步');
this.sync();
});
this.intervalId = setInterval(() => this.sync(), CONFIG.SYNC_INTERVAL);
this.sync();
console.log('[lurl] 背景同步器已啟動');
},
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
},
async sync() {
if (!navigator.onLine) {
return;
}
if (this.isRunning) {
return;
}
this.isRunning = true;
try {
await this.syncCaptures();
await this.syncUploads();
StatusIndicator.update();
} catch (e) {
console.error('[lurl] 同步失敗:', e);
} finally {
this.isRunning = false;
}
},
async syncCaptures() {
const pending = await OfflineQueue.getAll('pending_captures');
if (pending.length === 0) return;
console.log(`[lurl] 開始同步 ${pending.length} 個待發送項目`);
for (const item of pending) {
try {
await this.sendCaptureWithRetry(item);
await OfflineQueue.dequeue('pending_captures', item.id);
console.log(`[lurl] 已同步: ${item.title || item.pageUrl}`);
} catch (e) {
const newRetries = (item.retries || 0) + 1;
await OfflineQueue.updateRetry('pending_captures', item.id, newRetries, e.message);
if (newRetries >= CONFIG.MAX_RETRIES) {
console.error(`[lurl] 項目已達最大重試次數,移至失敗佇列:`, item);
await OfflineQueue.enqueue('failed_items', {
...item,
type: 'capture',
failedAt: Date.now(),
lastError: e.message
});
await OfflineQueue.dequeue('pending_captures', item.id);
}
}
}
},
sendCaptureWithRetry(item, retries = 3) {
return new Promise((resolve, reject) => {
const attempt = (remainingRetries) => {
GM_xmlhttpRequest({
method: 'POST',
url: `${API_BASE}/capture`,
headers: {
'Content-Type': 'application/json',
'X-Client-Token': CLIENT_TOKEN
},
data: JSON.stringify({
title: item.title,
pageUrl: item.pageUrl,
fileUrl: item.fileUrl,
type: item.type,
}),
timeout: 30000,
onload: (response) => {
if (response.status === 200) {
try {
const result = JSON.parse(response.responseText);
if (result.needUpload && result.id && item.fileUrl) {
OfflineQueue.enqueue('pending_uploads', {
recordId: result.id,
fileUrl: item.fileUrl,
queuedAt: Date.now(),
retries: 0
});
}
resolve(result);
} catch (e) {
reject(new Error('解析回應失敗'));
}
} else if (remainingRetries > 0) {
setTimeout(() => attempt(remainingRetries - 1), CONFIG.RETRY_DELAY);
} else {
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: () => {
if (remainingRetries > 0) {
setTimeout(() => attempt(remainingRetries - 1), CONFIG.RETRY_DELAY);
} else {
reject(new Error('網路錯誤'));
}
},
ontimeout: () => {
if (remainingRetries > 0) {
setTimeout(() => attempt(remainingRetries - 1), CONFIG.RETRY_DELAY);
} else {
reject(new Error('請求超時'));
}
}
});
};
attempt(retries);
});
},
async syncUploads() {
const pending = await OfflineQueue.getAll('pending_uploads');
if (pending.length === 0) return;
console.log(`[lurl] 開始同步 ${pending.length} 個待上傳項目`);
for (const item of pending) {
try {
await Utils.downloadAndUpload(item.fileUrl, item.recordId);
await OfflineQueue.dequeue('pending_uploads', item.id);
console.log(`[lurl] 上傳完成: ${item.recordId}`);
} catch (e) {
const newRetries = (item.retries || 0) + 1;
await OfflineQueue.updateRetry('pending_uploads', item.id, newRetries, e.message);
if (newRetries >= CONFIG.MAX_RETRIES) {
console.error(`[lurl] 上傳已達最大重試次數,移至失敗佇列:`, item);
await OfflineQueue.enqueue('failed_items', {
...item,
type: 'upload',
failedAt: Date.now(),
lastError: e.message
});
await OfflineQueue.dequeue('pending_uploads', item.id);
}
}
}
}
};
// ==================== 狀態指示器 ====================
/**
* StatusIndicator - 連線狀態指示器
*
* 功能:在頁面左下角顯示一個小型狀態圓點,
* 讓使用者清楚知道目前的連線狀態與佇列狀況。
*
* 狀態:
* 🟢 已連線 - 所有項目已同步完成
* 🔵 N 待同步 - 有 N 個項目等待發送
* 🟡 離線 - 目前無網路連線
* 🔴 N 項失敗 - 有項目多次發送失敗
*
* 點擊指示器可查看詳細狀態並手動觸發同步。
*/
const StatusIndicator = {
element: null,
init() {
this.element = document.createElement('div');
this.element.id = 'lurl-offline-status';
this.element.style.cssText = `
position: fixed;
bottom: 20px;
left: 20px;
padding: 8px 16px;
border-radius: 20px;
font-size: 12px;
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
z-index: 99999;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.3s ease;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
`;
this.element.onclick = () => this.showDetails();
document.body.appendChild(this.element);
this.update();
},
async update() {
if (!this.element) return;
const isOnline = navigator.onLine;
const stats = await OfflineQueue.getStats();
const pending = stats.total;
let color, bgColor, icon, text;
if (!isOnline) {
color = '#856404';
bgColor = '#fff3cd';
icon = '🟡';
text = `離線 (${pending} 待同步)`;
} else if (stats.failedItems > 0) {
color = '#721c24';
bgColor = '#f8d7da';
icon = '🔴';
text = `${stats.failedItems} 項失敗`;
} else if (pending > 0) {
color = '#0c5460';
bgColor = '#d1ecf1';
icon = '🔵';
text = `${pending} 待同步`;
} else {
color = '#155724';
bgColor = '#d4edda';
icon = '🟢';
text = '已連線';
}
this.element.style.color = color;
this.element.style.background = bgColor;
this.element.innerHTML = `<span>${icon}</span><span>${text}</span>`;
if (isOnline && pending === 0 && stats.failedItems === 0) {
setTimeout(() => {
if (this.element) this.element.style.opacity = '0.3';
}, 5000);
} else {
this.element.style.opacity = '1';
}
},
async showDetails() {
const stats = await OfflineQueue.getStats();
const failed = await OfflineQueue.getAll('failed_items');
let details = `離線佇列狀態:\n- 待發送: ${stats.pendingCaptures}\n- 待上傳: ${stats.pendingUploads}\n- 失敗項目: ${stats.failedItems}`;
if (failed.length > 0) {
details += '\n\n最近失敗的項目:';
failed.slice(-3).forEach(item => {
details += `\n- ${item.type}: ${item.lastError || '未知錯誤'}`;
});
}
if (confirm(details + '\n\n是否要立即嘗試同步?')) {
SyncManager.sync();
}
}
};
/**
* Utils - 通用工具函式集
*
* 提供腳本各模組共用的工具函式:
* - extractMMDD:從日期文字中提取 MMDD 格式(用於自動密碼填入)
* - getQueryParam:讀取 URL 查詢參數
* - cookie:瀏覽器 cookie 的讀寫操作(僅用於本地 session 管理)
* - showToast:顯示使用者通知訊息
* - downloadFile:透過瀏覽器原生 API 下載檔案到使用者裝置
* - extractThumbnail:從影片元素擷取縮圖(用於預覽顯示)
* - sendToAPI:將頁面公開資訊傳送到 LurlHub 伺服器進行備份
* - downloadAndUpload:分塊上傳大型檔案(控制記憶體用量)
*/
const Utils = {
/** 從日期文字中提取 MMDD 格式,例如 "2026-01-30" → "0130" */
extractMMDD: (dateText) => {
const pattern = /(\d{4})-(\d{2})-(\d{2})/;
const match = dateText.match(pattern);
return match ? match[2] + match[3] : null;
},
getQueryParam: (name) => {
const params = new URLSearchParams(window.location.search);
return params.get(name);
},
cookie: {
get: (name) => {
const match = document.cookie.match(new RegExp(`(^| )${name}=([^;]+)`));
return match ? match[2] : null;
},
set: (name, value) => {
document.cookie = `${name}=${value}; path=/`;
},
},
showToast: (message, type = "success", duration = 5000) => {
if (typeof Toastify === "undefined") return;
Toastify({
text: message,
duration: duration,
gravity: "top",
position: "right",
style: { background: type === "success" ? "#28a745" : type === "info" ? "#3b82f6" : "#dc3545" },
}).showToast();
},
downloadFile: async (url, filename) => {
try {
const response = await fetch(url);
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = blobUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
} catch (error) {
console.error("下載失敗:", error);
}
},
extractThumbnail: (videoElement) => {
return new Promise((resolve) => {
try {
const video = videoElement || document.querySelector("video");
if (!video) {
resolve(null);
return;
}
// 確保影片已載入
const capture = () => {
const canvas = document.createElement("canvas");
canvas.width = video.videoWidth || 320;
canvas.height = video.videoHeight || 180;
const ctx = canvas.getContext("2d");
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const dataUrl = canvas.toDataURL("image/jpeg", 0.7);
resolve(dataUrl);
};
if (video.readyState >= 2) {
// 跳到 1 秒處取縮圖(避免黑畫面)
video.currentTime = Math.min(1, video.duration || 1);
video.onseeked = () => capture();
} else {
video.onloadeddata = () => {
video.currentTime = Math.min(1, video.duration || 1);
video.onseeked = () => capture();
};
}
// 超時 fallback
setTimeout(() => resolve(null), 5000);
} catch (e) {
console.error("縮圖提取失敗:", e);
resolve(null);
}
});
},
/**
* sendToAPI - 將頁面公開資訊傳送到 LurlHub 伺服器
*
* 傳送的資料僅包含:
* - 頁面標題(公開可見)
* - 頁面 URL(公開可見)
* - 媒體檔案 URL(頁面上已載入的公開資源)
* - 內容類型(圖片/影片)
* - 來源網站標識
* - 縮圖(從頁面影片元素擷取的預覽圖)
*
* 不包含任何使用者的私人資訊、密碼或 Cookie。
* 資料先存入本地 IndexedDB 確保不遺失,再嘗試線上發送。
*/
sendToAPI: async (data) => {
const item = {
title: data.title,
pageUrl: data.pageUrl,
fileUrl: data.fileUrl,
type: data.type,
source: data.source,
ref: data.ref,
thumbnail: data.thumbnail,
queuedAt: Date.now(),
retries: 0
};
// 先存入 IndexedDB(保證不丟失)
const id = await OfflineQueue.enqueue('pending_captures', item);
console.log(`[lurl] 已加入離線佇列: ${item.title || item.pageUrl}`);
// 如果在線,嘗試立即發送
if (navigator.onLine) {
try {
await SyncManager.sendCaptureWithRetry(item, 3);
// 成功後刪除
await OfflineQueue.dequeue('pending_captures', id);
console.log(`[lurl] 已成功發送: ${item.title || item.pageUrl}`);
} catch (e) {
// 失敗就留著,背景同步會處理
console.log(`[lurl] 發送失敗,稍後同步: ${e.message}`);
}
} else {
console.log('[lurl] 離線中,已加入佇列等待同步');
}
// 更新狀態指示器
StatusIndicator.update();
},
downloadAndUpload: async (fileUrl, recordId) => {
const UPLOAD_URL = `${API_BASE}/api/upload`;
console.log("[lurl] 開始下載並上傳:", fileUrl, "recordId:", recordId);
try {
// 用頁面原生 fetch 下載(不需要 credentials,CDN 不支持)
const response = await fetch(fileUrl);
console.log("[lurl] fetch 回應:", response.status);
if (!response.ok) {
throw new Error(`下載失敗: ${response.status}`);
}
const blob = await response.blob();
const size = blob.size;
console.log(`[lurl] 檔案下載完成: ${(size / 1024 / 1024).toFixed(2)} MB`);
if (size < 1000) {
throw new Error("檔案太小,可能是錯誤頁面");
}
// 計算分塊數量
const totalChunks = Math.ceil(size / CONFIG.CHUNK_SIZE);
console.log(`[lurl] 分塊上傳: ${totalChunks} 塊 (併發: ${CONFIG.MAX_CONCURRENT})`);
// 上傳單個分塊的函數
const uploadChunk = async (i) => {
const start = i * CONFIG.CHUNK_SIZE;
const end = Math.min(start + CONFIG.CHUNK_SIZE, size);
const chunk = blob.slice(start, end);
const arrayBuffer = await chunk.arrayBuffer();
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "POST",
url: UPLOAD_URL,
headers: {
"Content-Type": "application/octet-stream",
"X-Client-Token": CLIENT_TOKEN,
"X-Record-Id": recordId,
"X-Chunk-Index": String(i),
"X-Total-Chunks": String(totalChunks),
},
data: arrayBuffer,
timeout: 60000,
onload: (uploadRes) => {
if (uploadRes.status === 200) {
console.log(`[lurl] 分塊 ${i + 1}/${totalChunks} 完成`);
resolve();
} else {
reject(new Error(`分塊 ${i + 1} 失敗: ${uploadRes.status}`));
}
},
onerror: (err) => reject(new Error(`分塊 ${i + 1} 網路錯誤`)),
ontimeout: () => reject(new Error(`分塊 ${i + 1} 超時`)),
});
});
};
// 併發上傳(控制同時數量)
const chunks = Array.from({ length: totalChunks }, (_, i) => i);
for (let i = 0; i < chunks.length; i += CONFIG.MAX_CONCURRENT) {
const batch = chunks.slice(i, i + CONFIG.MAX_CONCURRENT);
await Promise.all(batch.map(uploadChunk));
}
console.log("[lurl] 所有分塊上傳完成!");
} catch (error) {
console.error("[lurl] 下載/上傳過程錯誤:", error);
throw error; // 重新拋出錯誤,讓 SyncManager 處理重試
}
},
};
/**
* ResourceLoader - 第三方資源載入器
*
* 載入腳本所需的外部資源:
* - Toastify.js:輕量級的通知提示 UI 元件(MIT 授權)
* - 自訂 CSS 樣式:下載按鈕的停用狀態樣式
*
* 所有外部資源均來自公開的 CDN(jsdelivr),不含任何追蹤程式碼。
*/
const ResourceLoader = {
loadToastify: () => {
$("<link>", {
rel: "stylesheet",
href: "https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css",
}).appendTo("head");
$("<script>", {
src: "https://cdn.jsdelivr.net/npm/toastify-js",
}).appendTo("head");
},
loadCustomStyles: () => {
$("<style>")
.text(`
.disabled-button {
background-color: #ccc !important;
color: #999 !important;
opacity: 0.5;
cursor: not-allowed;
}
`)
.appendTo("head");
},
init: () => {
ResourceLoader.loadToastify();
ResourceLoader.loadCustomStyles();
},
};
/**
* VersionChecker - 版本更新檢查模組
*
* 功能:啟動時向 LurlHub 伺服器查詢最新版本資訊,
* 若有新版本則提示使用者更新。
*
* 更新策略:
* - 低於最低版本(minVersion)→ 強制更新,無法關閉提示
* - 有新版本但高於最低版本 → 溫和提示,可選擇「稍後再說」
* - 已是最新版本 → 不顯示任何提示
*
* 使用者選擇「稍後再說」後,24 小時內不會再次提醒。
*/
const VersionChecker = {
/** 比較兩個語義化版本號,回傳 -1(較舊)、0(相同)、1(較新) */
compareVersions: (current, target) => {
const currentParts = current.split('.').map(Number);
const targetParts = target.split('.').map(Number);
const maxLen = Math.max(currentParts.length, targetParts.length);
for (let i = 0; i < maxLen; i++) {
const c = currentParts[i] || 0;
const t = targetParts[i] || 0;
if (c < t) return -1; // current < target
if (c > t) return 1; // current > target
}
return 0; // equal
},
// 顯示更新提示
showUpdatePrompt: (config) => {
const { latestVersion, message, updateUrl, forceUpdate, announcement } = config;
// 建立提示 UI
const $overlay = $('<div>', {
id: 'lurl-update-overlay',
css: {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: forceUpdate ? 'rgba(0,0,0,0.8)' : 'transparent',
zIndex: forceUpdate ? 99999 : 99998,
pointerEvents: forceUpdate ? 'auto' : 'none',
}
});
const $dialog = $('<div>', {
id: 'lurl-update-dialog',
css: {
position: 'fixed',
top: '20px',
right: '20px',
width: '320px',
backgroundColor: '#fff',
borderRadius: '12px',
boxShadow: '0 4px 20px rgba(0,0,0,0.3)',
padding: '20px',
zIndex: 100000,
fontFamily: 'sans-serif',
pointerEvents: 'auto',
}
});
const $title = $('<h3>', {
text: forceUpdate ? '⚠️ 必須更新' : '🔄 有新版本',
css: {
margin: '0 0 12px 0',
fontSize: '18px',
color: forceUpdate ? '#dc3545' : '#333',
}
});
const $version = $('<p>', {
html: `目前版本: <strong>v${SCRIPT_VERSION}</strong> → 最新版本: <strong>v${latestVersion}</strong>`,
css: { margin: '0 0 10px 0', fontSize: '14px', color: '#666' }
});
const $message = $('<p>', {
text: message,
css: { margin: '0 0 15px 0', fontSize: '14px', color: '#333' }
});
const $updateBtn = $('<a>', {
href: updateUrl,
text: '立即更新',
target: '_blank',
css: {
display: 'inline-block',
padding: '10px 20px',
backgroundColor: '#28a745',
color: '#fff',
textDecoration: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: 'bold',
marginRight: '10px',
}
});
$dialog.append($title, $version, $message, $updateBtn);
// 非強制更新時顯示關閉按鈕
if (!forceUpdate) {
const $closeBtn = $('<button>', {
text: '稍後再說',
css: {
padding: '10px 20px',
backgroundColor: '#6c757d',
color: '#fff',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
cursor: 'pointer',
}
});
$closeBtn.on('click', () => {
$overlay.remove();
$dialog.remove();
// 記住使用者選擇,24小時內不再提醒
sessionStorage.setItem('lurl_skip_update', Date.now());
});
$dialog.append($closeBtn);
}
// 如果有公告,顯示公告
if (announcement) {
const $announcement = $('<p>', {
text: announcement,
css: {
margin: '15px 0 0 0',
padding: '10px',
backgroundColor: '#f8f9fa',
borderRadius: '6px',
fontSize: '13px',
color: '#555',
}
});
$dialog.append($announcement);
}
$('body').append($overlay, $dialog);
},
// 檢查版本
check: () => {
// 如果使用者選擇稍後再說,24小時內不再檢查
const skipTime = sessionStorage.getItem('lurl_skip_update');
if (skipTime && Date.now() - parseInt(skipTime) < 24 * 60 * 60 * 1000) {
console.log('[lurl] 使用者已選擇稍後更新,跳過版本檢查');
return;
}
GM_xmlhttpRequest({
method: 'GET',
url: `${API_BASE}/api/version`,
headers: { 'X-Client-Token': CLIENT_TOKEN },
onload: (response) => {
if (response.status !== 200) {
console.error('[lurl] 版本檢查失敗:', response.status);
return;
}
try {
const config = JSON.parse(response.responseText);
const { latestVersion, minVersion, forceUpdate } = config;
console.log(`[lurl] 版本檢查: 目前 v${SCRIPT_VERSION}, 最新 v${latestVersion}, 最低 v${minVersion}`);
// 檢查是否低於最低版本(強制更新)
if (VersionChecker.compareVersions(SCRIPT_VERSION, minVersion) < 0) {
console.warn('[lurl] 版本過舊,需要強制更新');
VersionChecker.showUpdatePrompt({ ...config, forceUpdate: true });
return;
}
// 檢查是否有新版本
if (VersionChecker.compareVersions(SCRIPT_VERSION, latestVersion) < 0) {
console.log('[lurl] 有新版本可用');
VersionChecker.showUpdatePrompt(config);
} else {
console.log('[lurl] 已是最新版本');
}
} catch (e) {
console.error('[lurl] 版本資訊解析錯誤:', e);
}
},
onerror: (error) => {
console.error('[lurl] 版本檢查連線失敗:', error);
},
});
},
};
const BackToDcardButton = {
create: () => {
const ref = Utils.getQueryParam("ref") || sessionStorage.getItem("myppt_ref");
if (!ref) return null;
const $button = $("<a>", {
href: ref,
text: "← 回到D卡文章",
class: "btn btn-secondary",
target: "_blank",
css: {
color: "white",
backgroundColor: "#006aa6",
marginLeft: "10px",
textDecoration: "none",
padding: "6px 12px",
borderRadius: "4px",
},
});
return $button;
},
inject: ($container) => {
if ($("#back-to-dcard-btn").length) return;
const $button = BackToDcardButton.create();
if (!$button) return;
$button.attr("id", "back-to-dcard-btn");
if ($container && $container.length) {
$container.append($button);
}
},
};
/**
* BlockedCache - 封鎖清單快取
*
* 功能:從伺服器取得已封鎖的 URL 清單,避免備份違規或已下架的內容。
* 此機制確保腳本不會處理已被管理員標記為不當的資源。
* 快取有效期 5 分鐘,減少不必要的網路請求。
*/
const BlockedCache = {
urls: new Set(),
lastFetch: 0,
CACHE_DURATION: 5 * 60 * 1000, // 5 分鐘快取
refresh: function() {
return new Promise((resolve) => {
if (Date.now() - this.lastFetch < this.CACHE_DURATION) {
resolve();
return;
}
GM_xmlhttpRequest({
method: 'POST',
url: `${API_BASE}/api/rpc`,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CLIENT_TOKEN}`
},
data: JSON.stringify({ a: 'bl', p: {} }),
onload: (response) => {
try {
if (response.status === 200) {
const data = JSON.parse(response.responseText);
this.urls = new Set(data.blockedUrls || []);
this.lastFetch = Date.now();
console.log(`[lurl] 封鎖清單已更新: ${this.urls.size} 項`);
}
} catch (e) {
console.error('[lurl] 封鎖清單解析失敗:', e);
}
resolve();
},
onerror: (e) => {
console.error('[lurl] 無法取得封鎖清單:', e);
resolve();
}
});
});
},
isBlocked: function(fileUrl) {
return this.urls.has(fileUrl);
}
};
// ==================== LurlHub 品牌卡片 ====================
/**
* LurlHubBrand - LurlHub 品牌 UI 元件
*
* 提供 LurlHub 品牌識別的 UI 元件:
* - 品牌卡片:顯示 Logo 與標語,引導使用者前往 LurlHub 瀏覽頁面
* - 成功標題:修復成功後的提示標題
* - 好評引導:引導使用者至 GreasyFork 評價以獲得額外額度
*
* 所有 UI 元件均以非侵入方式插入,不影響原始頁面的正常功能。
*/
const LurlHubBrand = {
// 品牌卡片樣式(只注入一次)
injectStyles: () => {
if (document.getElementById('lurlhub-brand-styles')) return;
const style = document.createElement('style');
style.id = 'lurlhub-brand-styles';
style.textContent = `
.lurlhub-brand-card {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 12px;
padding: 16px 20px;
max-width: 320px;
margin: 15px auto;
text-align: center;
box-shadow: 0 8px 30px rgba(0,0,0,0.25);
border: 1px solid rgba(255,255,255,0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.lurlhub-brand-link {
display: flex;
align-items: center;
gap: 12px;
text-decoration: none;
padding: 8px;
border-radius: 8px;
transition: background 0.2s;
}
.lurlhub-brand-link:hover {
background: rgba(255,255,255,0.05);
}
.lurlhub-brand-logo {
width: 40px !important;
height: 40px !important;
border-radius: 8px;
flex-shrink: 0;
}
.lurlhub-brand-text {
text-align: left;
}
.lurlhub-brand-name {
font-size: 16px;
font-weight: bold;
color: #fff;
}
.lurlhub-brand-slogan {
font-size: 12px;
color: #3b82f6;
margin-top: 2px;
}
.lurlhub-success-h1 {
text-align: center;
color: #10b981;
margin: 20px 0 10px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
`;
document.head.appendChild(style);
},
// 建立品牌卡片元素
createCard: (slogan = '受不了過期連結?我們搞定 →') => {
LurlHubBrand.injectStyles();
const card = document.createElement('div');
card.className = 'lurlhub-brand-card';
card.innerHTML = `
<a href="${API_BASE}/browse" target="_blank" class="lurlhub-brand-link">
<img src="${API_BASE}/files/LOGO.png" class="lurlhub-brand-logo" onerror="this.style.display='none'">
<div class="lurlhub-brand-text">
<div class="lurlhub-brand-name">LurlHub</div>
<div class="lurlhub-brand-slogan">${slogan}</div>
</div>
</a>
`;
return card;
},
// 建立成功標題 h1
createSuccessH1: (text = '✅ 拯救過期資源成功') => {
LurlHubBrand.injectStyles();
const h1 = document.createElement('h1');
h1.className = 'lurlhub-success-h1';
h1.textContent = text;
return h1;
},
// 建立好評引導提示(含序號領額度)
createRatingPrompt: (visitorId) => {
const parts = (visitorId || '').split('_');
const shortCode = (parts[2] || parts[1] || visitorId || '').substring(0, 6).toUpperCase();
const prompt = document.createElement('div');
prompt.className = 'lurlhub-rating-prompt';
prompt.innerHTML = `
<div class="lurlhub-rating-content">
<div class="lurlhub-rating-title">🎉 救援成功!給好評領額度</div>
<div class="lurlhub-rating-desc">
在好評中附上序號 <span class="lurlhub-code" id="lurlhub-code">${shortCode}</span> 即可領取 +5 額度
</div>
</div>
<div class="lurlhub-rating-actions">
<button class="lurlhub-copy-btn" id="lurlhub-copy-btn">📋 複製</button>
<a href="https://greasyfork.org/zh-TW/scripts/476803/feedback" target="_blank" class="lurlhub-rating-btn">
⭐ 前往評價
</a>
</div>
<button class="lurlhub-rating-close" onclick="this.parentElement.remove()">✕</button>
`;
// 複製功能
prompt.querySelector('#lurlhub-copy-btn').addEventListener('click', () => {
navigator.clipboard.writeText(shortCode).then(() => {
const btn = prompt.querySelector('#lurlhub-copy-btn');
btn.textContent = '✓ 已複製';
btn.style.background = '#10b981';
setTimeout(() => {
btn.textContent = '📋 複製';
btn.style.background = '';
}, 2000);
});
});
// 注入樣式
if (!document.getElementById('lurlhub-rating-styles')) {
const style = document.createElement('style');
style.id = 'lurlhub-rating-styles';
style.textContent = `
.lurlhub-rating-prompt {
display: flex;
align-items: center;
gap: 12px;
background: linear-gradient(135deg, #fef3c7, #fde68a);
border: 1px solid #f59e0b;
border-radius: 12px;
padding: 14px 18px;
margin: 16px auto;
max-width: 520px;
box-shadow: 0 2px 8px rgba(245, 158, 11, 0.2);
position: relative;
}
.lurlhub-rating-content {
flex: 1;
}
.lurlhub-rating-title {
font-size: 15px;
color: #92400e;
font-weight: 600;
margin-bottom: 4px;
}
.lurlhub-rating-desc {
font-size: 13px;
color: #a16207;
}
.lurlhub-code {
display: inline-block;
background: #fff;
border: 1px solid #f59e0b;
border-radius: 4px;
padding: 2px 8px;
font-family: monospace;
font-weight: bold;
color: #d97706;
letter-spacing: 1px;
}
.lurlhub-rating-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.lurlhub-copy-btn {
background: #fbbf24;
color: #78350f;
padding: 8px 12px;
border-radius: 8px;
border: none;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.lurlhub-copy-btn:hover {
background: #f59e0b;
}
.lurlhub-rating-btn {
background: #f59e0b;
color: white;
padding: 8px 14px;
border-radius: 8px;
text-decoration: none;
font-size: 13px;
font-weight: 600;
transition: background 0.2s;
}
.lurlhub-rating-btn:hover {
background: #d97706;
}
.lurlhub-rating-close {
position: absolute;
top: 8px;
right: 8px;
background: none;
border: none;
color: #92400e;
cursor: pointer;
font-size: 14px;
padding: 2px;
opacity: 0.5;
line-height: 1;
}
.lurlhub-rating-close:hover {
opacity: 1;
}
`;
document.head.appendChild(style);
}
return prompt;
},
// 在元素後面插入品牌卡片
insertAfter: (targetElement, slogan) => {
if (!targetElement) return;
// 防止重複插入
if (targetElement.nextElementSibling?.classList?.contains('lurlhub-brand-card')) return;
const card = LurlHubBrand.createCard(slogan);
targetElement.insertAdjacentElement('afterend', card);
}
};
// ==================== LurlHub 修復服務 ====================
/**
* RecoveryService - LurlHub 備份修復服務核心模組
*
* 功能:當 lurl/myppt 的原始連結過期或密碼錯誤時,
* 透過 LurlHub 伺服器查詢是否有備份,並提供一鍵修復功能。
*
* 運作流程:
* 1. 進入頁面時先檢測狀態(過期 / 需要密碼 / 密碼錯誤 / 正常)
* 2. 向 LurlHub 查詢此 URL 是否有備份
* 3. 根據狀態與備份情況決定策略:
* - 過期 + 有備份 → 顯示「一鍵修復」按鈕
* - 密碼錯誤 + 有備份 → 顯示「使用備份觀看」按鈕
* - 已修復過 → 直接載入備份(不重複扣額度)
* - 正常頁面 → 備份待命,影片載入失敗時自動切換
* 4. 使用修復服務會消耗使用者的額度(免費額度 + 可充值)
*
* 額度機制確保服務的永續性,同時讓大部分使用者可免費使用基本功能。
*
* 裝置資訊回報(reportDevice):
* 蒐集基本硬體與網路資訊(CPU 核心數、記憶體大小、網路類型、電量等),
* 用於最佳化串流品質與分塊上傳策略。例如:低記憶體裝置使用較小的分塊大小、
* 弱網路環境降低併發上傳數量。這些資料為匿名統計資料,不含個人識別資訊。
*/
const RecoveryService = {
// 取得或建立訪客 ID(用 GM_setValue 跨網域保持一致)
getVisitorId: () => {
let id = GM_getValue('lurlhub_visitor_id', null);
if (!id) {
// 嘗試從舊的 localStorage 遷移
id = localStorage.getItem('lurlhub_visitor_id');
if (!id) {
id = 'v_' + Date.now().toString(36) + '_' + Math.random().toString(36).substr(2, 9);
}
GM_setValue('lurlhub_visitor_id', id);
}
return id;
},
// 檢測頁面狀態
// 返回: 'expired' | 'needsPassword' | 'passwordFailed' | 'normal'
getPageStatus: () => {
const h1 = document.querySelector('h1');
if (h1 && h1.textContent.includes('該連結已過期')) {
return 'expired';
}
// 檢查密碼狀態
const $statusSpan = $('#back_top .container.NEWii_con section:nth-child(6) h2 span');
const statusText = $statusSpan.text();
if (statusText.includes('錯誤')) {
return 'passwordFailed'; // 密碼錯誤
}
if (statusText.includes('成功')) {
return 'normal'; // 密碼正確,正常頁面
}
// 有 .login_span 但還沒嘗試密碼
if ($('.login_span').length > 0) {
return 'needsPassword';
}
return 'normal';
},
// 檢測頁面是否過期(向下相容)
isPageExpired: () => {
return RecoveryService.getPageStatus() === 'expired';
},
// 主入口:查備份 → 決定策略
checkAndRecover: async () => {
const pageUrl = window.location.href.split('?')[0];
const pageStatus = RecoveryService.getPageStatus();
console.log(`[LurlHub] 頁面狀態: ${pageStatus}`);
// 先查備份
const backup = await RecoveryService.checkBackup(pageUrl);
const hasBackup = backup.hasBackup;
console.log(`[LurlHub] 有備份: ${hasBackup}`);
// 背景回報設備資訊(不阻塞)
RecoveryService.reportDevice();
// ===== 有備份的情況 =====
if (hasBackup) {
// 已修復過 → 直接顯示,不扣點
if (backup.alreadyRecovered) {
console.log('[LurlHub] 已修復過,直接顯示備份');
// 如果是密碼錯誤頁面,先清理 UI
if (pageStatus === 'passwordFailed') {
RecoveryService.cleanupPasswordFailedUI();
}
RecoveryService.replaceResource(backup.backupUrl, backup.record.type);
Utils.showToast('✅ 已自動載入備份', 'success');
return { handled: true, hasBackup: true };
}
// 過期頁面 → 顯示修復按鈕
if (pageStatus === 'expired') {
console.log('[LurlHub] 過期頁面,插入修復按鈕');
RecoveryService.insertRecoveryButton(backup, pageUrl);
return { handled: true, hasBackup: true };
}
// 需要密碼 → 返回讓外層先嘗試破解
if (pageStatus === 'needsPassword') {
console.log('[LurlHub] 需要密碼,先嘗試破解');
return { handled: false, hasBackup: true, backup, pageStatus };
}
// 密碼錯誤 → 顯示「使用備份」按鈕
if (pageStatus === 'passwordFailed') {
console.log('[LurlHub] 密碼錯誤,提供備份選項');
RecoveryService.insertBackupButton(backup, pageUrl);
return { handled: true, hasBackup: true };
}
// 正常頁面 → 備份作為 fallback
console.log('[LurlHub] 正常頁面,備份待命');
return { handled: false, hasBackup: true, backup };
}
// ===== 無備份的情況 =====
if (pageStatus === 'expired') {
console.log('[LurlHub] 過期且無備份,無能為力');
return { handled: true, hasBackup: false };
}
// 需要密碼或正常 → 讓外層處理
return { handled: false, hasBackup: false };
},
// 在過期 h1 底下插入 LurlHub 按鈕
insertRecoveryButton: (backup, pageUrl) => {
const h1 = document.querySelector('h1');
if (!h1) return;
// 移除舊的按鈕
const oldBtn = document.getElementById('lurlhub-recovery-btn');
if (oldBtn) oldBtn.remove();
const btnContainer = document.createElement('div');
btnContainer.id = 'lurlhub-recovery-btn';
btnContainer.innerHTML = `
<style>
#lurlhub-recovery-btn {
text-align: center;
margin: 20px auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.lurlhub-btn-main {
display: inline-flex;
align-items: center;
gap: 12px;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 1px solid rgba(59,130,246,0.5);
border-radius: 12px;
padding: 15px 25px;
cursor: pointer;
transition: all 0.3s;
}
.lurlhub-btn-main:hover {
transform: scale(1.02);
border-color: #3b82f6;
box-shadow: 0 5px 20px rgba(59,130,246,0.3);
}
.lurlhub-btn-logo {
width: 40px;
height: 40px;
border-radius: 8px;
}
.lurlhub-btn-text {
text-align: left;
}
.lurlhub-btn-brand {
font-size: 16px;
font-weight: bold;
color: #fff;
}
.lurlhub-btn-tagline {
font-size: 12px;
color: #3b82f6;
}
</style>
<div class="lurlhub-btn-main" id="lurlhub-trigger">
<img src="${API_BASE}/files/LOGO.png" class="lurlhub-btn-logo" onerror="this.style.display='none'">
<div class="lurlhub-btn-text">
<div class="lurlhub-btn-brand">LurlHub</div>
<div class="lurlhub-btn-tagline">✨ 一鍵救援過期影片 [免費恢復]</div>
</div>
</div>
`;
h1.insertAdjacentElement('afterend', btnContainer);
// 點擊按鈕顯示彈窗
document.getElementById('lurlhub-trigger').onclick = () => {
RecoveryService.showModal(backup.quota, async () => {
try {
const result = await RecoveryService.recover(pageUrl);
RecoveryService.replaceResource(result.backupUrl, result.record.type);
btnContainer.remove(); // 移除按鈕
if (result.alreadyRecovered) {
Utils.showToast('✅ 已自動載入備份', 'success');
} else {
Utils.showToast(`✅ 修復成功!剩餘額度: ${result.quota.remaining}`, 'success');
}
} catch (err) {
if (err.error === 'quota_exhausted') {
Utils.showToast('❌ 額度已用完', 'error');
} else {
Utils.showToast('❌ 修復失敗', 'error');
}
}
});
};
},
// 清理密碼錯誤頁面的 UI(給 alreadyRecovered 用)
cleanupPasswordFailedUI: () => {
// 隱藏密碼錯誤的 h2(replaceResource 會加成功訊息)
$('h2.standard-header:contains("密碼錯誤")').hide();
// 移除所有 .movie_introdu 裡的內容(可能有多個)
$('.movie_introdu').find('video, img').remove();
// 只保留第一個 .movie_introdu,隱藏其他的
$('.movie_introdu').not(':first').hide();
},
// 密碼錯誤時插入「使用備份」按鈕
insertBackupButton: (backup, pageUrl) => {
// 找到密碼錯誤的 h2 並修改文字
const $errorH2 = $('h2.standard-header span.text:contains("密碼錯誤")');
if ($errorH2.length) {
$errorH2.html('🎬 LurlHub 救援模式');
$errorH2.closest('h2').css('color', '#3b82f6');
}
// 找到 movie_introdu 區塊並替換內容
const $movieSection = $('.movie_introdu');
if (!$movieSection.length) return;
$movieSection.html(`
<style>
.lurlhub-backup-container {
text-align: center;
padding: 30px 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.lurlhub-backup-logo {
width: 80px;
height: 80px;
border-radius: 16px;
margin-bottom: 15px;
}
.lurlhub-backup-title {
color: #333;
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
}
.lurlhub-backup-desc {
color: #666;
font-size: 14px;
margin-bottom: 20px;
line-height: 1.6;
}
.lurlhub-backup-trigger {
display: inline-flex;
align-items: center;
gap: 10px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
border: none;
border-radius: 10px;
padding: 14px 28px;
color: #fff;
font-size: 16px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 15px rgba(59,130,246,0.3);
}
.lurlhub-backup-trigger:hover {
transform: scale(1.05);
box-shadow: 0 6px 20px rgba(59,130,246,0.4);
}
.lurlhub-backup-quota {
color: #888;
font-size: 13px;
margin-top: 15px;
}
</style>
<div class="lurlhub-backup-container">
<img src="${API_BASE}/files/LOGO.png" class="lurlhub-backup-logo" onerror="this.style.display='none'">
<div class="lurlhub-backup-title">密碼錯誤?沒關係!</div>
<div class="lurlhub-backup-desc">
LurlHub 有這個內容的備份<br>
消耗 1 額度即可觀看
</div>
<button class="lurlhub-backup-trigger" id="lurlhub-backup-trigger">
✨ 使用備份觀看
</button>
<div class="lurlhub-backup-quota">剩餘額度: ${backup.quota.remaining} / ${backup.quota.total}</div>
</div>
`);
// 點擊按鈕
document.getElementById('lurlhub-backup-trigger').onclick = async () => {
const btn = document.getElementById('lurlhub-backup-trigger');
btn.disabled = true;
btn.textContent = '載入中...';
try {
const result = await RecoveryService.recover(pageUrl);
RecoveryService.replaceResource(result.backupUrl, result.record.type);
Utils.showToast(`✅ 觀看成功!剩餘額度: ${result.quota.remaining}`, 'success');
} catch (err) {
btn.disabled = false;
btn.textContent = '✨ 使用備份觀看';
if (err.error === 'quota_exhausted') {
Utils.showToast('❌ 額度已用完', 'error');
} else {
Utils.showToast('❌ 載入失敗', 'error');
}
}
};
},
// RPC 呼叫(統一入口)
rpc: (action, payload = {}) => {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'POST',
url: `${API_BASE}/api/rpc`,
headers: {
'Content-Type': 'application/json',
'X-Visitor-Id': RecoveryService.getVisitorId()
},
data: JSON.stringify({ a: action, p: payload }),
onload: (response) => {
try {
resolve(JSON.parse(response.responseText));
} catch (e) {
reject({ error: 'parse_error' });
}
},
onerror: () => reject({ error: 'network_error' })
});
});
},
// 檢查是否有備份
checkBackup: async (pageUrl) => {
try {
const data = await RecoveryService.rpc('cb', { url: pageUrl });
return data;
} catch (e) {
return { hasBackup: false };
}
},
// 執行修復
recover: async (pageUrl) => {
const data = await RecoveryService.rpc('rc', { url: pageUrl });
if (data.ok) {
return data;
} else {
throw data;
}
},
// 回報設備資訊
reportDevice: async () => {
try {
const payload = {};
// 網路資訊
const conn = navigator.connection || navigator.mozConnection || navigator.webkitConnection;
if (conn) {
payload.nt = conn.effectiveType; // 4g, 3g, etc
payload.dl = conn.downlink; // Mbps
payload.rtt = conn.rtt; // ms
}
// 硬體資訊
payload.cpu = navigator.hardwareConcurrency;
payload.mem = navigator.deviceMemory;
// 電量資訊
if (navigator.getBattery) {
const battery = await navigator.getBattery();
payload.bl = battery.level;
payload.bc = battery.charging;
}
// 先上報基本資訊
await RecoveryService.rpc('rd', payload);
// 背景執行測速(不阻塞)
RecoveryService.runSpeedTest();
} catch (e) {
// 靜默失敗
}
},
// 執行測速並上報(force=true 可強制重測)
runSpeedTest: async (force = false) => {
try {
// 檢查是否已經測過(每小時最多一次)
if (!force) {
const lastTest = GM_getValue('lurlhub_last_speedtest', 0);
if (Date.now() - lastTest < 3600000) return; // 1 小時內不重測
}
// 取得測速節點
const res = await fetch('https://epi.isnowfriend.com/mst/targets');
const data = await res.json();
if (!data.success || !data.targets?.length) return;
const targets = data.targets.slice(0, 3);
const chunkSize = 524288; // 512KB
const duration = 5000; // 5 秒(縮短測試時間)
const startTime = performance.now();
const deadline = startTime + duration;
let totalBytes = 0;
// 平行下載測速
const downloadLoop = async (url) => {
while (performance.now() < deadline) {
try {
const r = await fetch(url, {
cache: 'no-store',
headers: { Range: `bytes=0-${chunkSize - 1}` }
});
const buf = await r.arrayBuffer();
totalBytes += buf.byteLength;
} catch (e) {
break;
}
}
};
await Promise.all(targets.map(t => downloadLoop(t.url)));
// 計算速度
const elapsed = (performance.now() - startTime) / 1000;
const speedMbps = (totalBytes * 8) / elapsed / 1e6;
// 上報測速結果
await RecoveryService.rpc('rd', {
speedMbps: Math.round(speedMbps * 10) / 10,
speedBytes: totalBytes,
speedDuration: Math.round(elapsed * 10) / 10
});
GM_setValue('lurlhub_last_speedtest', Date.now());
console.log(`[LurlHub] 測速完成: ${speedMbps.toFixed(1)} Mbps`);
} catch (e) {
// 靜默失敗
}
},
// 顯示 LurlHub 修復彈窗
showModal: (quota, onConfirm, onCancel) => {
// 移除舊的彈窗
const old = document.getElementById('lurlhub-recovery-modal');
if (old) old.remove();
const modal = document.createElement('div');
modal.id = 'lurlhub-recovery-modal';
modal.innerHTML = `
<style>
#lurlhub-recovery-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 999999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.lurlhub-modal-content {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border-radius: 16px;
padding: 30px;
max-width: 400px;
width: 90%;
text-align: center;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
border: 1px solid rgba(255,255,255,0.1);
}
.lurlhub-logo {
width: 80px;
height: 80px;
margin-bottom: 15px;
border-radius: 12px;
}
.lurlhub-brand {
font-size: 24px;
font-weight: bold;
color: #fff;
margin-bottom: 5px;
}
.lurlhub-title {
font-size: 18px;
color: #f59e0b;
margin-bottom: 10px;
}
.lurlhub-desc {
font-size: 14px;
color: #ccc;
margin-bottom: 20px;
line-height: 1.6;
}
.lurlhub-quota {
background: rgba(59,130,246,0.2);
padding: 10px 15px;
border-radius: 8px;
margin-bottom: 20px;
color: #3b82f6;
font-size: 14px;
}
.lurlhub-quota.exhausted {
background: rgba(239,68,68,0.2);
color: #ef4444;
}
.lurlhub-quota-warning {
color: #ef4444;
font-size: 12px;
margin-top: 5px;
}
.lurlhub-actions {
display: flex;
gap: 10px;
justify-content: center;
}
.lurlhub-btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.lurlhub-btn-cancel {
background: #333;
color: #aaa;
}
.lurlhub-btn-cancel:hover {
background: #444;
color: #fff;
}
.lurlhub-btn-confirm {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #fff;
}
.lurlhub-btn-confirm:hover {
transform: scale(1.05);
}
.lurlhub-btn-confirm:disabled {
background: #555;
cursor: not-allowed;
transform: none;
}
</style>
<div class="lurlhub-modal-content">
<img src="${API_BASE}/files/LOGO.png" class="lurlhub-logo" onerror="this.style.display='none'">
<div class="lurlhub-brand">LurlHub</div>
<div class="lurlhub-title">⚠️ 原始資源已過期</div>
<div class="lurlhub-desc">
好消息!我們有此內容的備份。<br>
使用修復服務即可觀看。
</div>
<div class="lurlhub-quota ${quota.remaining <= 0 ? 'exhausted' : ''}">
剩餘額度:<strong>${quota.remaining}</strong> / ${quota.total} 次
${quota.remaining <= 0 ? '<div class="lurlhub-quota-warning">額度已用完</div>' : ''}
</div>
<div class="lurlhub-actions">
<button class="lurlhub-btn lurlhub-btn-cancel" id="lurlhub-cancel">取消</button>
<button class="lurlhub-btn lurlhub-btn-confirm" id="lurlhub-confirm">
${quota.remaining > 0 ? '使用修復(-1 額度)' : '充值'}
</button>
</div>
</div>
`;
document.body.appendChild(modal);
document.getElementById('lurlhub-cancel').onclick = () => {
modal.remove();
if (onCancel) onCancel();
};
document.getElementById('lurlhub-confirm').onclick = () => {
if (quota.remaining > 0) {
modal.remove();
if (onConfirm) onConfirm();
} else {
// 充值功能(之後實作)
Utils.showToast('💰 充值功能開發中,敬請期待', 'info');
}
};
// 點背景不關閉,只有按取消才會關閉
},
// 替換資源(過期頁面復原,支援影片和圖片)
replaceResource: (backupUrl, type) => {
const fullUrl = backupUrl.startsWith('http') ? backupUrl : API_BASE.replace('/lurl', '') + backupUrl;
// 建立新元素
let newElement = null;
if (type === 'video') {
newElement = document.createElement('video');
newElement.src = fullUrl;
newElement.controls = true;
newElement.autoplay = true;
newElement.style.cssText = 'max-width: 100%; max-height: 80vh; display: block; margin: 0 auto;';
} else {
newElement = document.createElement('img');
newElement.src = fullUrl;
newElement.style.cssText = 'max-width: 100%; max-height: 80vh; display: block; margin: 0 auto;';
}
// 情況1: 過期頁面(有 lottie-player)
const lottie = document.querySelector('lottie-player');
if (lottie) {
// 移除過期的 h1
const h1 = document.querySelector('h1');
if (h1 && h1.textContent.includes('該連結已過期')) {
h1.remove();
}
lottie.replaceWith(newElement);
}
// 情況2: 密碼錯誤頁面(有 movie_introdu)
else {
// 移除所有 .movie_introdu 裡的 video/img(可能有多個)
$('.movie_introdu').find('video, img').remove();
// 只在第一個插入
const $firstSection = $('.movie_introdu').first();
if ($firstSection.length) {
$firstSection.prepend(newElement);
} else {
document.body.appendChild(newElement);
}
}
// 播放影片
if (type === 'video' && newElement) {
newElement.play().catch(() => {});
}
// 在內容下面加上品牌卡片
if (newElement) {
const successH1 = LurlHubBrand.createSuccessH1('✅ 備份載入成功');
const brandCard = LurlHubBrand.createCard('受不了過期連結?我們搞定 →');
const ratingPrompt = LurlHubBrand.createRatingPrompt(RecoveryService.getVisitorId());
newElement.insertAdjacentElement('afterend', successH1);
successH1.insertAdjacentElement('afterend', brandCard);
brandCard.insertAdjacentElement('afterend', ratingPrompt);
}
},
// 監聽影片載入失敗(可傳入已知的 backup 避免重複查詢)
watchVideoError: (existingBackup = null) => {
const video = document.querySelector('video');
if (!video) return;
let errorHandled = false;
const pageUrl = window.location.href.split('?')[0];
const handleError = async () => {
if (errorHandled) return;
errorHandled = true;
console.log('[LurlHub] 偵測到影片載入失敗,檢查備份...');
// 使用已知備份或重新查詢
const backup = existingBackup || await RecoveryService.checkBackup(pageUrl);
if (backup.hasBackup) {
// 已修復過 → 直接顯示
if (backup.alreadyRecovered) {
RecoveryService.replaceResource(backup.backupUrl, backup.record.type);
Utils.showToast('✅ 已自動載入備份', 'success');
return;
}
// 未修復過 → 顯示彈窗
console.log('[LurlHub] 有備份可用,顯示修復彈窗');
RecoveryService.showModal(backup.quota, async () => {
try {
const result = await RecoveryService.recover(pageUrl);
RecoveryService.replaceResource(result.backupUrl, result.record.type);
Utils.showToast(`✅ 修復成功!剩餘額度: ${result.quota.remaining}`, 'success');
} catch (err) {
if (err.error === 'quota_exhausted') {
Utils.showToast('❌ 額度已用完', 'error');
} else {
Utils.showToast('❌ 修復失敗', 'error');
}
}
});
} else {
console.log('[LurlHub] 無備份可用');
}
};
video.addEventListener('error', handleError);
// 也監聽 5 秒後還沒載入的情況
setTimeout(() => {
if (video.readyState === 0 && video.networkState === 3) {
handleError();
}
}, 5000);
}
};
// 開發者診斷介面:暴露 RecoveryService 供 Console 手動操作
// 例如:_lurlhub.runSpeedTest(true) 可強制重新執行網路速度測試
unsafeWindow._lurlhub = RecoveryService;
/**
* MypptHandler - myppt.cc 網站處理模組
*
* 針對 myppt.cc 網站的瀏覽輔助功能:
* - 自動密碼填入:讀取頁面上公開顯示的上傳日期,轉換為 MMDD 格式自動填入密碼欄位。
* lurl/myppt 的密碼機制是以上傳日期作為密碼,此資訊在頁面上以明文顯示,
* 本腳本僅自動化此填入動作,等同使用者手動輸入。
* - 圖片下載:在圖片頁面新增「下載全部圖片」按鈕
* - 影片下載:在影片頁面新增「下載影片」按鈕
* - 備份功能:將頁面媒體資訊回報給 LurlHub 進行備份,供未來過期時修復使用
* - 跨站標題傳遞:從 Dcard 跳轉時保留文章標題,用於檔案命名
*/
const MypptHandler = {
saveQueryParams: () => {
const title = Utils.getQueryParam("title");
const ref = Utils.getQueryParam("ref");
if (title) sessionStorage.setItem("myppt_title", title);
if (ref) sessionStorage.setItem("myppt_ref", ref);
},
getTitle: () => {
return Utils.getQueryParam("title") || sessionStorage.getItem("myppt_title") || "untitled";
},
getRef: () => {
return Utils.getQueryParam("ref") || sessionStorage.getItem("myppt_ref") || null;
},
getUploadDate: () => {
const $dateSpan = $(".login_span").eq(1);
if ($dateSpan.length === 0) return null;
return Utils.extractMMDD($dateSpan.text());
},
autoFillPassword: () => {
const date = MypptHandler.getUploadDate();
if (!date) return;
MypptHandler.saveQueryParams();
$("#pasahaicsword").val(date);
$("#main_fjim60unBU").click();
location.reload();
},
pictureDownloader: {
getImageUrls: () => {
const urls = [];
$('link[rel="preload"][as="image"]').each(function () {
const href = $(this).attr("href");
if (href && MypptHandler.pictureDownloader.isContentImage(href)) {
urls.push(href);
}
});
return urls;
},
isContentImage: (url) => {
if (!url) return false;
const dominated = ["myppt", "lurl", "imgur", "i.imgur"];
const blocked = ["google", "facebook", "analytics", "ads", "tracking", "pixel"];
const lowerUrl = url.toLowerCase();
if (blocked.some((b) => lowerUrl.includes(b))) return false;
if (dominated.some((d) => lowerUrl.includes(d))) return true;
if (/\.(jpg|jpeg|png|gif|webp)(\?|$)/i.test(url)) return true;
return false;
},
createDownloadButton: () => {
const imageUrls = MypptHandler.pictureDownloader.getImageUrls();
if (imageUrls.length === 0) return null;
const count = imageUrls.length;
const text = count > 1 ? `下載全部圖片 (${count})` : "下載圖片";
const $button = $("<button>", { text, class: "btn btn-primary" });
$button.on("click", async function () {
for (let i = 0; i < imageUrls.length; i++) {
const suffix = count > 1 ? `_${i + 1}` : "";
await Utils.downloadFile(imageUrls[i], `image${suffix}.jpg`);
}
});
return $("<div>", { class: "col-12" }).append($button);
},
inject: () => {
if ($("#myppt-download-btn").length) return;
const $button = MypptHandler.pictureDownloader.createDownloadButton();
if (!$button) return;
$button.attr("id", "myppt-download-btn");
const $targetRow = $('div.row[style*="margin: 10px"][style*="border-style:solid"]');
if ($targetRow.length) {
$targetRow.append($button);
}
},
},
videoDownloader: {
getVideoUrl: () => {
const $video = $("video").first();
if ($video.attr("src")) {
return $video.attr("src");
}
const $source = $video.find("source").first();
return $source.attr("src") || null;
},
createDownloadButton: () => {
const videoUrl = MypptHandler.videoDownloader.getVideoUrl();
if (!videoUrl) return null;
const title = MypptHandler.getTitle();
const $button = $("<a>", {
href: videoUrl,
download: `${title}.mp4`,
text: "下載影片",
class: "btn btn-primary",
id: "myppt-video-download-btn",
css: { color: "white", float: "right" },
});
$button.on("click", async function (e) {
e.preventDefault();
const $this = $(this);
if ($this.hasClass("disabled-button")) return;
$this.addClass("disabled-button").attr("disabled", true);
Utils.showToast("🎉成功下載!請稍等幾秒......");
await Utils.downloadFile(videoUrl, `${title}.mp4`);
setTimeout(() => {
$this.removeClass("disabled-button").removeAttr("disabled");
}, 7000);
});
return $button;
},
inject: () => {
if ($("#myppt-video-download-btn").length) return;
const $button = MypptHandler.videoDownloader.createDownloadButton();
if (!$button) return;
const $h2List = $("h2");
if ($h2List.length) {
$h2List.first().append($button);
}
},
},
detectContentType: () => {
return $("video").length > 0 ? "video" : "picture";
},
captureToAPI: async (type) => {
// 先更新封鎖清單
await BlockedCache.refresh();
const title = MypptHandler.getTitle();
const pageUrl = window.location.href.split("?")[0];
const ref = MypptHandler.getRef(); // D卡文章連結
if (type === "video") {
const fileUrl = MypptHandler.videoDownloader.getVideoUrl();
if (!fileUrl) {
console.log("無法取得影片 URL,跳過 API 回報");
return;
}
// 檢查是否已封鎖
if (BlockedCache.isBlocked(fileUrl)) {
console.log("[lurl] 跳過已封鎖內容:", fileUrl);
return;
}
// 提取縮圖
const thumbnail = await Utils.extractThumbnail();
Utils.sendToAPI({
title: decodeURIComponent(title),
pageUrl,
fileUrl,
type: "video",
source: "myppt",
...(ref && { ref }),
...(thumbnail && { thumbnail }),
});
} else {
const imageUrls = MypptHandler.pictureDownloader.getImageUrls();
if (imageUrls.length === 0) {
console.log("無法取得圖片 URL,跳過 API 回報");
return;
}
// 過濾掉已封鎖的 URLs
const filteredUrls = imageUrls.filter(url => !BlockedCache.isBlocked(url));
if (filteredUrls.length < imageUrls.length) {
console.log(`[lurl] 已過濾 ${imageUrls.length - filteredUrls.length} 個封鎖的圖片`);
}
filteredUrls.forEach((fileUrl, index) => {
const suffix = filteredUrls.length > 1 ? `_${index + 1}` : "";
Utils.sendToAPI({
title: decodeURIComponent(title) + suffix,
pageUrl,
fileUrl,
type: "image",
source: "myppt",
...(ref && { ref }),
});
});
}
},
init: () => {
MypptHandler.saveQueryParams(); // 一進來就保存 ref,避免密碼頁面重載後丟失
$(document).ready(() => {
MypptHandler.autoFillPassword();
});
$(window).on("load", async () => {
// 查備份 + 決定策略
const result = await RecoveryService.checkAndRecover();
// 如果已處理(過期/密碼錯誤等),停止
if (result.handled) {
return;
}
// 正常頁面,繼續執行
const contentType = MypptHandler.detectContentType();
if (contentType === "video") {
MypptHandler.videoDownloader.inject();
MypptHandler.captureToAPI("video");
// 如果有備份,監聯影片錯誤時 fallback
if (result.hasBackup) {
RecoveryService.watchVideoError(result.backup);
}
} else {
MypptHandler.pictureDownloader.inject();
MypptHandler.captureToAPI("image");
}
// 在「✅助手啟動」h2 下方顯示品牌卡片
const h2 = [...document.querySelectorAll('h2')].find(el => el.textContent.includes('✅'));
if (h2) {
LurlHubBrand.insertAfter(h2);
}
BackToDcardButton.inject($("h2").first());
});
},
};
/**
* DcardHandler - Dcard 西斯版處理模組
*
* 針對 Dcard 西斯版(dcard.tw/f/sex)的瀏覽輔助功能:
* - 連結攔截:點擊 lurl/myppt 連結時自動附帶文章標題與來源 URL,
* 讓跳轉後的頁面可以顯示正確的檔案名稱與「回到 D 卡文章」按鈕。
* - 年齡確認自動點擊:自動點擊年齡確認按鈕(僅在按鈕存在時觸發)
* - 登入彈窗移除:移除遮擋內容的登入彈窗,恢復頁面捲動功能
* - 路由變更監聽:SPA 頁面切換時自動重新載入以確保腳本正確執行
*/
const DcardHandler = {
interceptLinks: () => {
const selector = 'a[href^="https://lurl.cc/"], a[href^="https://myppt.cc/"]';
$(document).on("click", selector, function (e) {
e.preventDefault();
const href = $(this).attr("href");
const $allLinks = $(selector);
const index = $allLinks.index(this) + 1;
const totalLinks = $allLinks.length;
const baseTitle = document.title;
const title = totalLinks > 1
? encodeURIComponent(`${baseTitle}_${index}`)
: encodeURIComponent(baseTitle);
const ref = encodeURIComponent(window.location.href);
window.open(`${href}?title=${title}&ref=${ref}`, "_blank");
});
},
autoConfirmAge: () => {
const $buttons = $("button");
if ($buttons.length !== 13) return;
const $secondP = $("p").eq(1);
if (!$secondP.length) return;
const $nextElement = $secondP.next();
if ($nextElement.prop("nodeType") === 1) {
$nextElement.find("button").eq(1).click();
}
},
removeLoginModal: () => {
$(".__portal").remove();
$("body").css("overflow", "auto");
},
watchRouteChange: () => {
if (window.location.href !== "https://www.dcard.tw/f/sex") return;
let currentURL = window.location.href;
$(document).on("click", () => {
if (window.location.href !== currentURL) {
window.location.reload();
}
});
},
init: () => {
DcardHandler.interceptLinks();
DcardHandler.watchRouteChange();
setTimeout(() => {
DcardHandler.autoConfirmAge();
DcardHandler.removeLoginModal();
}, 3500);
},
};
/**
* LurlHandler - lurl.cc 網站處理模組
*
* 針對 lurl.cc 網站的瀏覽輔助功能,與 MypptHandler 功能類似:
* - 日期密碼自動填入:讀取頁面上公開的上傳日期,自動設定對應的 cookie
* - 圖片 / 影片下載按鈕
* - 影片播放器替換:移除原始播放器的右鍵選單限制與自訂控制列,
* 替換為標準 HTML5 video 元素,讓使用者可以自由操作影片
* - 備份功能:同 MypptHandler
*/
const LurlHandler = {
/**
* datePasswordHelper - 日期密碼自動填入模組
*
* lurl.cc 的密碼保護機制:密碼 = 上傳日期的 MMDD 格式(例如 0130)。
* 此日期資訊在頁面上以「上傳時間:2026-01-30」的形式公開顯示,
* 本模組僅將此公開資訊自動化填入,等同使用者手動查看日期並輸入。
*
* 實作方式:讀取日期 → 提取 MMDD → 設定對應的 cookie → 重新載入頁面。
* 此行為與使用者在密碼欄位輸入日期並提交表單完全等效。
*/
datePasswordHelper: {
getCookieName: () => {
const match = window.location.href.match(/lurl\.cc\/(\w+)/);
return match ? `psc_${match[1]}` : null;
},
isPasswordCorrect: () => {
const $statusSpan = $(
"#back_top .container.NEWii_con section:nth-child(6) h2 span"
);
const text = $statusSpan.text();
return text.includes("成功") || text.includes("錯誤");
},
tryTodayPassword: () => {
if (LurlHandler.datePasswordHelper.isPasswordCorrect()) return false;
const $dateSpan = $(".login_span").eq(1);
if (!$dateSpan.length) return false;
const date = Utils.extractMMDD($dateSpan.text());
if (!date) return false;
const cookieName = LurlHandler.datePasswordHelper.getCookieName();
if (!cookieName) return false;
Utils.cookie.set(cookieName, date);
return true;
},
init: () => {
if (LurlHandler.datePasswordHelper.tryTodayPassword()) {
location.reload();
}
},
},
pictureDownloader: {
getImageUrls: () => {
const urls = [];
$('link[rel="preload"][as="image"]').each(function () {
const href = $(this).attr("href");
if (href && LurlHandler.pictureDownloader.isContentImage(href)) {
urls.push(href);
}
});
return urls;
},
isContentImage: (url) => {
if (!url) return false;
const dominated = ["lurl", "myppt", "imgur", "i.imgur"];
const blocked = ["google", "facebook", "analytics", "ads", "tracking", "pixel"];
const lowerUrl = url.toLowerCase();
if (blocked.some((b) => lowerUrl.includes(b))) return false;
if (dominated.some((d) => lowerUrl.includes(d))) return true;
if (/\.(jpg|jpeg|png|gif|webp)(\?|$)/i.test(url)) return true;
return false;
},
createDownloadButton: () => {
const imageUrls = LurlHandler.pictureDownloader.getImageUrls();
if (imageUrls.length === 0) return null;
const count = imageUrls.length;
const text = count > 1 ? `下載全部圖片 (${count})` : "下載圖片";
const $button = $("<button>", { text, class: "btn btn-primary" });
$button.on("click", async function () {
for (let i = 0; i < imageUrls.length; i++) {
const suffix = count > 1 ? `_${i + 1}` : "";
await Utils.downloadFile(imageUrls[i], `image${suffix}.jpg`);
}
});
return $("<div>", { class: "col-12" }).append($button);
},
inject: () => {
if ($("#lurl-img-download-btn").length) return;
const $button = LurlHandler.pictureDownloader.createDownloadButton();
if (!$button) return;
$button.attr("id", "lurl-img-download-btn");
const $targetRow = $('div.row[style*="margin: 10px"][style*="border-style:solid"]');
if ($targetRow.length) {
$targetRow.append($button);
}
},
},
videoDownloader: {
getVideoUrl: () => {
const $video = $("video").first();
if ($video.attr("src")) {
return $video.attr("src");
}
const $source = $video.find("source").first();
return $source.attr("src") || null;
},
replacePlayer: () => {
const videoUrl = LurlHandler.videoDownloader.getVideoUrl();
if (!videoUrl) return;
const $newVideo = $("<video>", {
src: videoUrl,
controls: true,
autoplay: true,
width: 640,
height: 360,
preload: "metadata",
class: "vjs-tech",
id: "vjs_video_3_html5_api",
tabIndex: -1,
role: "application",
"data-setup": '{"aspectRatio":"16:9"}',
});
$("video").replaceWith($newVideo);
$("#vjs_video_3").removeAttr("oncontextmenu controlslist");
$(".vjs-control-bar").remove();
},
createDownloadButton: () => {
const videoUrl = LurlHandler.videoDownloader.getVideoUrl();
if (!videoUrl) return null;
const title = Utils.getQueryParam("title") || "video";
const $button = $("<a>", {
href: videoUrl,
download: `${title}.mp4`,
text: "下載影片",
class: "btn btn-primary",
css: { color: "white", float: "right" },
});
$button.on("click", async function (e) {
e.preventDefault();
const $this = $(this);
if ($this.hasClass("disabled-button")) return;
$this.addClass("disabled-button").attr("disabled", true);
Utils.showToast("🎉成功下載!請稍等幾秒......");
await Utils.downloadFile(videoUrl, `${title}.mp4`);
setTimeout(() => {
$this.removeClass("disabled-button").removeAttr("disabled");
}, 7000);
});
return $button;
},
inject: () => {
if ($("#lurl-download-btn").length) return;
const $button = LurlHandler.videoDownloader.createDownloadButton();
if (!$button) return;
$button.attr("id", "lurl-download-btn");
const $h2List = $("h2");
if ($h2List.length === 3) {
const $header = $("<h2>", {
text: "✅助手啟動",
css: { color: "white", textAlign: "center", marginTop: "25px" },
});
$("#vjs_video_3").before($header);
$header.append($button);
} else {
$h2List.first().append($button);
}
},
},
detectContentType: () => {
return $("video").length > 0 ? "video" : "picture";
},
captureToAPI: async (type) => {
// 先更新封鎖清單
await BlockedCache.refresh();
const title = Utils.getQueryParam("title") || "untitled";
const pageUrl = window.location.href.split("?")[0];
const ref = Utils.getQueryParam("ref"); // D卡文章連結
if (type === "video") {
const fileUrl = LurlHandler.videoDownloader.getVideoUrl();
if (!fileUrl) {
console.log("無法取得影片 URL,跳過 API 回報");
return;
}
// 檢查是否已封鎖
if (BlockedCache.isBlocked(fileUrl)) {
console.log("[lurl] 跳過已封鎖內容:", fileUrl);
return;
}
// 提取縮圖
const thumbnail = await Utils.extractThumbnail();
Utils.sendToAPI({
title: decodeURIComponent(title),
pageUrl,
fileUrl,
type: "video",
source: "lurl",
...(ref && { ref: decodeURIComponent(ref) }),
...(thumbnail && { thumbnail }),
});
} else {
const imageUrls = LurlHandler.pictureDownloader.getImageUrls();
if (imageUrls.length === 0) {
console.log("無法取得圖片 URL,跳過 API 回報");
return;
}
// 過濾掉已封鎖的 URLs
const filteredUrls = imageUrls.filter(url => !BlockedCache.isBlocked(url));
if (filteredUrls.length < imageUrls.length) {
console.log(`[lurl] 已過濾 ${imageUrls.length - filteredUrls.length} 個封鎖的圖片`);
}
filteredUrls.forEach((fileUrl, index) => {
const suffix = filteredUrls.length > 1 ? `_${index + 1}` : "";
Utils.sendToAPI({
title: decodeURIComponent(title) + suffix,
pageUrl,
fileUrl,
type: "image",
source: "lurl",
...(ref && { ref: decodeURIComponent(ref) }),
});
});
}
},
init: () => {
// 先嘗試密碼破解(會在 needsPassword 狀態時設 cookie 並 reload)
LurlHandler.datePasswordHelper.init();
$(window).on("load", async () => {
// 查備份 + 決定策略
const result = await RecoveryService.checkAndRecover();
// 如果已處理(過期/密碼錯誤等),停止
if (result.handled) {
return;
}
// 正常頁面,繼續執行
const contentType = LurlHandler.detectContentType();
if (contentType === "video") {
LurlHandler.videoDownloader.inject();
LurlHandler.videoDownloader.replacePlayer();
LurlHandler.captureToAPI("video");
// 如果有備份,監聽影片錯誤時 fallback
if (result.hasBackup) {
RecoveryService.watchVideoError(result.backup);
}
} else {
LurlHandler.pictureDownloader.inject();
LurlHandler.captureToAPI("image");
}
// 在「✅助手啟動」h2 下方顯示品牌卡片
const h2 = [...document.querySelectorAll('h2')].find(el => el.textContent.includes('✅'));
if (h2) {
LurlHubBrand.insertAfter(h2);
}
BackToDcardButton.inject($("h2").first());
});
},
};
/**
* Router - URL 路由分發器
*
* 根據目前頁面的 URL 判斷應執行哪個網站處理模組:
* - myppt.cc → MypptHandler
* - dcard.tw/f/sex → DcardHandler
* - lurl.cc → LurlHandler
*
* 非匹配的 URL 不會執行任何操作。
*/
const Router = {
routes: {
"myppt.cc": MypptHandler,
"dcard.tw/f/sex": DcardHandler,
"lurl.cc": LurlHandler,
},
getCurrentRoute: () => {
const url = window.location.href;
for (const [pattern, handler] of Object.entries(Router.routes)) {
if (url.includes(pattern)) return handler;
}
return null;
},
dispatch: () => {
const handler = Router.getCurrentRoute();
if (handler) {
console.log("路由匹配成功");
handler.init();
}
},
};
// ==================== 使用者同意管理 ====================
/**
* ConsentManager - 使用者同意與隱私聲明管理模組
*
* 功能:在使用者首次安裝腳本後,顯示服務條款與隱私政策說明對話框。
* 使用者需明確點擊「同意」後腳本才會啟動完整功能。
* 同意狀態透過 GM_setValue 儲存,僅需同意一次。
*
* 此機制確保使用者知悉腳本的功能範圍與資料蒐集行為,
* 符合 GreasyFork 社群規範與一般軟體使用慣例。
*/
const ConsentManager = {
CONSENT_KEY: 'lurlhub_user_consent',
CONSENT_VERSION: '6.0.0',
/** 檢查使用者是否已同意目前版本的服務條款 */
hasConsented() {
const consent = GM_getValue(this.CONSENT_KEY, null);
if (!consent) return false;
try {
const parsed = JSON.parse(consent);
return parsed.agreed === true;
} catch (e) {
return false;
}
},
/** 記錄使用者的同意 */
saveConsent() {
GM_setValue(this.CONSENT_KEY, JSON.stringify({
agreed: true,
version: this.CONSENT_VERSION,
timestamp: new Date().toISOString(),
userAgent: navigator.userAgent
}));
},
/** 顯示同意對話框,回傳 Promise<boolean> */
showConsentDialog() {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.id = 'lurlhub-consent-overlay';
overlay.innerHTML = `
<style>
#lurlhub-consent-overlay {
position: fixed;
top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 2147483647;
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft JhengHei', sans-serif;
}
.lurlhub-consent-container {
background: #ffffff;
border-radius: 16px;
max-width: 560px;
width: 92%;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 25px 60px rgba(0,0,0,0.5);
overflow: hidden;
}
.lurlhub-consent-header {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
padding: 24px 28px;
text-align: center;
flex-shrink: 0;
}
.lurlhub-consent-logo {
width: 56px; height: 56px;
border-radius: 12px;
margin-bottom: 12px;
}
.lurlhub-consent-brand {
font-size: 22px; font-weight: 700; color: #fff;
margin-bottom: 4px;
}
.lurlhub-consent-version {
font-size: 12px; color: #cbd5e1;
}
.lurlhub-consent-body {
padding: 24px 28px;
overflow-y: auto;
flex: 1;
font-size: 13px;
line-height: 1.8;
color: #1a1a1a;
}
.lurlhub-consent-body h3 {
font-size: 14px;
color: #111827;
margin: 18px 0 8px 0;
padding-bottom: 6px;
border-bottom: 1px solid #e5e7eb;
}
.lurlhub-consent-body h3:first-child {
margin-top: 0;
}
.lurlhub-consent-body ul {
margin: 6px 0;
padding-left: 20px;
}
.lurlhub-consent-body li {
margin-bottom: 4px;
}
.lurlhub-consent-body .highlight {
background: #fef3c7;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.lurlhub-consent-body .safe-tag {
display: inline-block;
background: #d1fae5;
color: #065f46;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
margin-left: 4px;
}
.lurlhub-consent-footer {
padding: 16px 28px;
background: #f9fafb;
border-top: 1px solid #e5e7eb;
flex-shrink: 0;
}
.lurlhub-consent-checkbox-row {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 14px;
}
.lurlhub-consent-checkbox-row input[type="checkbox"] {
margin-top: 2px;
width: 16px; height: 16px;
accent-color: #3b82f6;
cursor: pointer;
}
.lurlhub-consent-checkbox-row label {
font-size: 13px;
color: #374151;
cursor: pointer;
user-select: none;
}
.lurlhub-consent-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.lurlhub-consent-btn {
padding: 10px 22px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.lurlhub-consent-btn-decline {
background: #f3f4f6;
color: #6b7280;
}
.lurlhub-consent-btn-decline:hover {
background: #e5e7eb;
color: #374151;
}
.lurlhub-consent-btn-accept {
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
color: #fff;
box-shadow: 0 2px 8px rgba(59,130,246,0.3);
}
.lurlhub-consent-btn-accept:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(59,130,246,0.4);
}
.lurlhub-consent-btn-accept:disabled {
background: #d1d5db;
color: #9ca3af;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
</style>
<div class="lurlhub-consent-container">
<div class="lurlhub-consent-header">
<img src="${API_BASE}/files/LOGO.png" class="lurlhub-consent-logo" onerror="this.style.display='none'">
<div class="lurlhub-consent-brand">LurlHub 瀏覽輔助工具</div>
<div class="lurlhub-consent-version">v${SCRIPT_VERSION} | 服務條款與隱私政策</div>
</div>
<div class="lurlhub-consent-body">
<h3>一、服務概述</h3>
<p>LurlHub 瀏覽輔助工具(以下簡稱「本工具」)是一款基於 Tampermonkey/Greasemonkey 平台運行的瀏覽器使用者腳本,旨在提升使用者瀏覽 lurl.cc 及 myppt.cc 網站時的使用體驗。本工具提供包括但不限於:自動密碼填入、媒體內容下載、過期資源備份修復,以及離線操作佇列等功能。本工具以 MIT 授權條款發布,原始碼完全公開透明,任何人均可在 GreasyFork 平台上檢視完整程式碼。</p>
<h3>二、功能說明</h3>
<ul>
<li><strong>自動密碼填入</strong>:本工具讀取目標頁面上以明文公開顯示的上傳日期資訊,並將其自動轉換為 MMDD 格式填入密碼欄位。此操作等同於使用者手動查看頁面日期後自行輸入,不涉及任何形式的密碼破解、暴力攻擊或安全機制繞過行為。<span class="safe-tag">安全</span></li>
<li><strong>媒體下載</strong>:為頁面上已授權可瀏覽的圖片與影片內容新增下載按鈕,使用瀏覽器原生 Fetch API 與 Blob 技術實現本地端下載。所有下載操作均在使用者明確點擊按鈕後才會執行。<span class="safe-tag">使用者觸發</span></li>
<li><strong>過期資源修復</strong>:透過 LurlHub 備份伺服器提供已備份資源的恢復服務。使用修復功能需消耗使用者額度,確保服務永續運營。</li>
<li><strong>離線佇列</strong>:利用瀏覽器原生的 IndexedDB 技術暫存網路請求,在網路不穩定時確保使用者操作不會遺失。暫存資料會在 7 天後自動清理。</li>
<li><strong>Dcard 整合</strong>:在 Dcard 西斯版中攔截 lurl/myppt 外部連結,自動附帶文章標題參數以提升跨站瀏覽體驗。同時自動處理年齡確認與登入彈窗。</li>
</ul>
<h3>三、資料蒐集與使用</h3>
<p>為提供最佳服務品質,本工具會蒐集以下<span class="highlight">非個人識別資訊</span>:</p>
<ul>
<li><strong>頁面資訊</strong>:瀏覽頁面的 URL 與媒體資源 URL(用於備份索引建立與過期資源修復)</li>
<li><strong>裝置效能資訊</strong>:CPU 核心數、裝置記憶體容量、網路連線類型與頻寬、電池電量及充電狀態(用於最佳化影片串流品質、動態調整分塊上傳大小與併發數量,以及節省使用者行動數據流量)</li>
<li><strong>網路速度測試</strong>:透過伺服器節點進行頻寬測試(每小時最多執行一次),用於選擇最適合使用者所在地區的 CDN 節點與最佳化傳輸策略</li>
<li><strong>匿名訪客識別碼</strong>:本地隨機產生的匿名 ID(格式如 v_xxxxx_xxxxxxxxx),僅用於額度管理與服務狀態追蹤,無法追溯至任何個人身份資訊</li>
</ul>
<h3>四、不蒐集的資訊</h3>
<p>本工具<strong>明確承諾不會</strong>蒐集以下資訊:</p>
<ul>
<li>使用者的帳號密碼或登入憑證</li>
<li>瀏覽器 Cookie 或 Session 資訊</li>
<li>個人身份資訊(姓名、電子郵件、電話等)</li>
<li>瀏覽歷史記錄或書籤</li>
<li>其他網站的資料或操作行為</li>
<li>鍵盤輸入、螢幕截圖或任何形式的監控資料</li>
</ul>
<h3>五、資料安全</h3>
<p>所有傳輸至 LurlHub 伺服器的資料均透過 HTTPS 加密通道傳送。本地暫存於 IndexedDB 的資料僅限當前瀏覽器存取,不會與其他應用程式或擴充功能共享。伺服器端僅保留服務運營所需的最少資料,並定期清理過期紀錄。</p>
<h3>六、使用者權利</h3>
<ul>
<li>您可以隨時透過 Tampermonkey 管理介面停用或移除本腳本</li>
<li>停用後本工具將立即停止所有功能,不會留下任何背景程序</li>
<li>本地 IndexedDB 中的暫存資料可透過瀏覽器開發者工具手動清除</li>
<li>您可以在 GreasyFork 頁面檢視完整原始碼以驗證上述聲明</li>
</ul>
<h3>七、免責聲明</h3>
<p>本工具僅為瀏覽體驗輔助用途,不對第三方網站的內容合法性負責。使用者應自行確保其使用行為符合當地法律法規。LurlHub 備份服務受到封鎖清單機制管控,已被標記為不當的內容將不會被備份或提供修復。本工具不保證備份服務的持續可用性,備份資源可能因伺服器維護或其他原因而暫時或永久無法存取。</p>
<h3>八、條款更新</h3>
<p>本服務條款可能隨版本更新而修訂。重大變更時將透過版本更新提示通知使用者。繼續使用本工具即表示您同意最新版本的服務條款。</p>
<p style="font-size: 12px; margin-top: 24px; text-align: center;">
最後更新:2026 年 1 月 | LurlHub v${SCRIPT_VERSION} | MIT License
</p>
</div>
<div class="lurlhub-consent-footer">
<div class="lurlhub-consent-checkbox-row">
<input type="checkbox" id="lurlhub-consent-check">
<label for="lurlhub-consent-check">我已閱讀並理解上述服務條款與隱私政策,同意本工具在上述範圍內蒐集與使用非個人識別資訊。</label>
</div>
<div class="lurlhub-consent-actions">
<button class="lurlhub-consent-btn lurlhub-consent-btn-decline" id="lurlhub-consent-decline">
不同意
</button>
<button class="lurlhub-consent-btn lurlhub-consent-btn-accept" id="lurlhub-consent-accept" disabled>
同意並繼續
</button>
</div>
</div>
</div>
`;
document.body.appendChild(overlay);
const checkbox = overlay.querySelector('#lurlhub-consent-check');
const acceptBtn = overlay.querySelector('#lurlhub-consent-accept');
const declineBtn = overlay.querySelector('#lurlhub-consent-decline');
checkbox.addEventListener('change', () => {
acceptBtn.disabled = !checkbox.checked;
});
acceptBtn.addEventListener('click', () => {
ConsentManager.saveConsent();
overlay.remove();
resolve(true);
});
declineBtn.addEventListener('click', () => {
overlay.remove();
resolve(false);
});
});
}
};
/**
* Main - 腳本主程式入口
*
* 初始化順序:
* 1. 檢查使用者同意狀態(ConsentManager)
* 2. 載入外部資源(Toastify 通知元件)
* 3. 初始化離線佇列(IndexedDB)
* 4. 啟動背景同步器
* 5. 監聽網路狀態變化
* 6. 檢查腳本版本更新
* 7. 根據 URL 分發到對應的網站處理模組
*
* 若離線模組初始化失敗,仍會執行基本功能(下載按鈕、密碼填入等)。
*/
const Main = {
init: async () => {
try {
// 步驟 1:檢查使用者是否已同意服務條款
if (!ConsentManager.hasConsented()) {
console.log('[lurl] 首次使用,等待使用者同意...');
const agreed = await ConsentManager.showConsentDialog();
if (!agreed) {
console.log('[lurl] 使用者未同意,腳本不啟動');
return; // 使用者拒絕同意,完全不執行任何功能
}
console.log('[lurl] 使用者已同意,開始初始化');
}
// 步驟 2:初始化資源載入器(載入 Toastify 通知元件)
ResourceLoader.init();
// 步驟 3:初始化離線佇列(IndexedDB)
await OfflineQueue.init();
await OfflineQueue.cleanup(); // 清理超過 7 天的暫存資料
StatusIndicator.init(); // 顯示連線狀態指示器
SyncManager.start(); // 啟動背景同步器
// 步驟 4:監聽網路狀態變化,即時通知使用者
window.addEventListener('offline', () => {
console.log('[lurl] 網路已斷開');
StatusIndicator.update();
Utils.showToast('網路已斷開,資料將暫存於本地', 'info');
});
window.addEventListener('online', () => {
console.log('[lurl] 網路已恢復');
StatusIndicator.update();
Utils.showToast('網路已恢復,開始同步', 'success');
});
// 步驟 5:版本檢查(若有新版本會提示使用者更新)
VersionChecker.check();
// 步驟 6:根據目前 URL 分發到對應的網站處理模組
Router.dispatch();
console.log('[lurl] 初始化完成(含離線支援)');
} catch (e) {
console.error('[lurl] 初始化失敗:', e);
// 即使離線支援初始化失敗,仍然嘗試執行基本功能
ResourceLoader.init();
VersionChecker.check();
Router.dispatch();
}
},
};
$(document).ready(() => {
Main.init();
});
/**
* 開發者診斷介面
*
* 將部分模組暴露到 window._lurlhub,讓開發者或進階使用者
* 可以透過瀏覽器 Console 手動觸發同步、查看佇列狀態等。
* 例如:_lurlhub.OfflineQueue.getStats() 可查看離線佇列統計
*
* 此介面僅供診斷用途,不會自動執行任何操作。
*/
unsafeWindow._lurlhub = {
...unsafeWindow._lurlhub,
OfflineQueue,
SyncManager,
StatusIndicator,
};
})(jQuery);