// ==UserScript== // @name 骨碌碌本地作品检索 // @namespace https://www.gululu.world/ // @version 0.1.0 // @description 为骨碌碌文字世界提供全库本地同步、组合筛选、排序、随机推送和历史记录筛选。 // @author Gululu Search Contributors // @match https://www.gululu.world/ // @connect backend.gululu.world // @grant GM_addStyle // @grant GM_getValue // @grant GM_setValue // @grant GM_xmlhttpRequest // @run-at document-idle // @license MIT // ==/UserScript== (() => { var __getOwnPropNames = Object.getOwnPropertyNames; var __esm = (fn, res) => function __init() { return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; }; // src/database.js function requestResult(request) { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); } function transactionDone(transaction) { return new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onerror = () => reject(transaction.error); transaction.onabort = () => reject(transaction.error || new Error("\u6570\u636E\u5E93\u4E8B\u52A1\u5DF2\u4E2D\u6B62")); }); } function openDatabase() { if (databasePromise) return databasePromise; databasePromise = new Promise((resolve, reject) => { const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); request.onupgradeneeded = () => { const database = request.result; const works = database.createObjectStore(WORKS_STORE, { keyPath: "id" }); works.createIndex("updateTimestamp", "updateTimestamp"); works.createIndex("authorId", "authorId"); database.createObjectStore(META_STORE, { keyPath: "key" }); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); }); return databasePromise; } async function putWorks(works) { if (!works.length) return; const database = await openDatabase(); const transaction = database.transaction(WORKS_STORE, "readwrite"); const store = transaction.objectStore(WORKS_STORE); for (const work of works) store.put(work); await transactionDone(transaction); } async function putWorksPreservingTags(works) { if (!works.length) return; const database = await openDatabase(); const readTransaction = database.transaction(WORKS_STORE, "readonly"); const readStore = readTransaction.objectStore(WORKS_STORE); const existing = await Promise.all(works.map((work) => requestResult(readStore.get(work.id)))); const merged = works.map((work, index) => { const cached = existing[index]; if (!cached?.tagsLoadedAt && !cached?.indexedTags?.length) return work; return { ...work, tags: cached.tags ?? [], tagsLoadedAt: cached.tagsLoadedAt, indexedTags: cached.indexedTags ?? [] }; }); await putWorks(merged); } async function applyCompleteTagIndex(tagName2, matchingIds) { const tag = String(tagName2 ?? "").trim(); if (!tag) return; const database = await openDatabase(); const transaction = database.transaction(WORKS_STORE, "readwrite"); const done = transactionDone(transaction); const store = transaction.objectStore(WORKS_STORE); await new Promise((resolve, reject) => { const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) return resolve(); const work = cursor.value; const indexedTags = new Set(Array.isArray(work.indexedTags) ? work.indexedTags : []); const tags = new Set(Array.isArray(work.tags) ? work.tags : []); const matches = matchingIds.has(String(work.id)); const changed = matches ? !indexedTags.has(tag) || !tags.has(tag) : indexedTags.has(tag) || tags.has(tag); if (matches) { indexedTags.add(tag); tags.add(tag); } else { indexedTags.delete(tag); tags.delete(tag); } if (changed) cursor.update({ ...work, indexedTags: [...indexedTags], tags: [...tags] }); cursor.continue(); }; request.onerror = () => reject(request.error); }); await done; } async function getAllWorks() { const database = await openDatabase(); const transaction = database.transaction(WORKS_STORE, "readonly"); return requestResult(transaction.objectStore(WORKS_STORE).getAll()); } async function countWorks() { const database = await openDatabase(); const transaction = database.transaction(WORKS_STORE, "readonly"); return requestResult(transaction.objectStore(WORKS_STORE).count()); } async function getMeta(key) { const database = await openDatabase(); const transaction = database.transaction(META_STORE, "readonly"); const record = await requestResult(transaction.objectStore(META_STORE).get(key)); return record?.value; } async function setMeta(key, value) { const database = await openDatabase(); const transaction = database.transaction(META_STORE, "readwrite"); transaction.objectStore(META_STORE).put({ key, value }); await transactionDone(transaction); } async function removeWorksNotSeenInRun(runId) { const database = await openDatabase(); const transaction = database.transaction(WORKS_STORE, "readwrite"); const done = transactionDone(transaction); const store = transaction.objectStore(WORKS_STORE); await new Promise((resolve, reject) => { const request = store.openCursor(); request.onsuccess = () => { const cursor = request.result; if (!cursor) return resolve(); if (cursor.value.lastFullSyncRun !== runId) cursor.delete(); cursor.continue(); }; request.onerror = () => reject(request.error); }); await done; } var DATABASE_NAME, DATABASE_VERSION, WORKS_STORE, META_STORE, databasePromise; var init_database = __esm({ "src/database.js"() { DATABASE_NAME = "gululu-enhanced-recommendations"; DATABASE_VERSION = 1; WORKS_STORE = "works"; META_STORE = "meta"; } }); // src/conditions.js function evaluateNode(node, matcher) { if (node?.type === "term") return matcher(node.value); if (node?.type !== "group" || !node.children?.length) return false; return node.operator === "AND" ? node.children.every((child) => evaluateNode(child, matcher)) : node.children.some((child) => evaluateNode(child, matcher)); } function evaluateForest(nodes, matcher) { return !nodes?.length || nodes.some((node) => evaluateNode(node, matcher)); } var init_conditions = __esm({ "src/conditions.js"() { } }); // src/search.js function optionalNumber(value) { if (value === "" || value == null) return null; const result = Number(value); return Number.isFinite(result) ? result : null; } function numericValue(work, field) { const result = Number(work[field]); if (Number.isFinite(result) && result !== 0) return result; if (field === "createTimestamp" && work.createTime) { const timestamp = Date.parse(String(work.createTime).replace(" ", "T")); if (Number.isFinite(timestamp)) return timestamp; } if (field === "updateTimestamp" && work.updateTime) { const timestamp = Date.parse(String(work.updateTime).replace(" ", "T")); if (Number.isFinite(timestamp)) return timestamp; } return Number.isFinite(result) ? result : 0; } function rangeFor(criteria, field) { const suffix = `${field[0].toUpperCase()}${field.slice(1)}`; return [optionalNumber(criteria[`min${suffix}`]), optionalNumber(criteria[`max${suffix}`])]; } function matchesRange(work, field, criteria) { const [minimum, maximum] = rangeFor(criteria, field); if (minimum != null && maximum != null && minimum > maximum) return false; const value = numericValue(work, field); return (minimum == null || value >= minimum) && (maximum == null || value <= maximum); } function compare(left, right, field) { if (["title", "authorName"].includes(field)) { return String(left[field] ?? "").localeCompare(String(right[field] ?? ""), "zh-CN"); } return numericValue(left, field) - numericValue(right, field); } function matchesTimeFilter(work, criteria, prefix, field, now) { const timestamp = numericValue(work, field); const mode = criteria[`${prefix}TimeMode`] ?? "recent"; if (mode === "recent") { const amount = optionalNumber(criteria[`${prefix}RecentValue`]); if (amount == null) return true; const unit = UNIT_MILLISECONDS[criteria[`${prefix}RecentUnit`]] ?? UNIT_MILLISECONDS.days; return timestamp >= now - amount * unit && timestamp <= now; } const startRaw = criteria[`${prefix}Start`]; const endRaw = criteria[`${prefix}End`]; const start2 = startRaw ? Date.parse(startRaw) : null; const end = endRaw ? Date.parse(endRaw) : null; if (start2 != null && end != null && start2 > end) return false; return (start2 == null || timestamp >= start2) && (end == null || timestamp <= end); } function seededRandom(seed) { let value = Number(seed) >>> 0; return () => { value += 1831565813; let result = value; result = Math.imul(result ^ result >>> 15, result | 1); result ^= result + Math.imul(result ^ result >>> 7, result | 61); return ((result ^ result >>> 14) >>> 0) / 4294967296; }; } function shuffle(items, seed) { const result = [...items]; const random = seededRandom(seed); for (let index = result.length - 1; index > 0; index -= 1) { const target = Math.floor(random() * (index + 1)); [result[index], result[target]] = [result[target], result[index]]; } return result; } function searchWorks(works, customCriteria = {}, context = {}) { const criteria = { ...DEFAULT_SEARCH, ...customCriteria }; const query = String(criteria.query ?? "").trim().toLocaleLowerCase(); const authorQuery = String(criteria.authorQuery ?? "").trim().toLocaleLowerCase(); const sortBy = SORT_FIELDS.includes(criteria.sortBy) ? criteria.sortBy : DEFAULT_SEARCH.sortBy; const direction = criteria.sortDirection === "asc" ? 1 : -1; const pageSize = Math.min(100, Math.max(1, Number(criteria.pageSize) || DEFAULT_SEARCH.pageSize)); const now = Number.isFinite(context.now) ? context.now : Date.now(); const matches = works.filter((work) => { if (criteria.type !== "all" && work.type !== criteria.type) return false; if (context.tagIds && !context.tagIds.has(String(work.id))) return false; if (context.excludeTagIds?.has(String(work.id))) return false; if (criteria.collectionMode === "include" && !context.collectionIds?.has(String(work.id))) return false; if (criteria.collectionMode === "exclude" && context.collectionIds?.has(String(work.id))) return false; if (criteria.historyMode === "include" && !context.historyIds?.has(String(work.id))) return false; if (criteria.historyMode === "exclude" && context.historyIds?.has(String(work.id))) return false; if (!matchesTimeFilter(work, criteria, "publish", "createTimestamp", now)) return false; if (!matchesTimeFilter(work, criteria, "update", "updateTimestamp", now)) return false; if (authorQuery && !String(work.authorName ?? "").toLocaleLowerCase().includes(authorQuery)) return false; const author = String(work.authorName ?? "").toLocaleLowerCase(); if (!evaluateForest(criteria.authorConditions, (term) => author.includes(String(term).toLocaleLowerCase()))) return false; if (criteria.excludeAuthorConditions?.length && evaluateForest(criteria.excludeAuthorConditions, (term) => author.includes(String(term).toLocaleLowerCase()))) return false; if (query) { const haystack = `${work.title ?? ""} ${work.authorName ?? ""} ${work.summary ?? ""}`.toLocaleLowerCase(); if (!haystack.includes(query)) return false; } if (!evaluateForest(criteria.keywordConditions, (term) => { const haystack = `${work.title ?? ""} ${work.authorName ?? ""} ${work.summary ?? ""}`.toLocaleLowerCase(); return haystack.includes(String(term).toLocaleLowerCase()); })) return false; if (criteria.excludeKeywordConditions?.length && evaluateForest(criteria.excludeKeywordConditions, (term) => { const haystack = `${work.title ?? ""} ${work.authorName ?? ""} ${work.summary ?? ""}`.toLocaleLowerCase(); return haystack.includes(String(term).toLocaleLowerCase()); })) return false; return NUMERIC_FIELDS.every((field) => matchesRange(work, field, criteria)); }); const ordered = criteria.mode === "random" ? shuffle(matches, criteria.randomSeed) : matches.sort( (left, right) => direction * (sortBy === "lastReadTimestamp" ? (context.historyReadTimes?.get(String(left.id)) ?? 0) - (context.historyReadTimes?.get(String(right.id)) ?? 0) : compare(left, right, sortBy)) || String(left.id).localeCompare(String(right.id)) ); const totalPages = Math.max(1, Math.ceil(ordered.length / pageSize)); const page = Math.min(totalPages, Math.max(1, Number(criteria.page) || 1)); const start2 = (page - 1) * pageSize; return { total: ordered.length, totalPages, page, pageSize, items: ordered.slice(start2, start2 + pageSize) }; } var NUMERIC_FIELDS, SORT_FIELDS, DEFAULT_SEARCH, UNIT_MILLISECONDS; var init_search = __esm({ "src/search.js"() { init_conditions(); NUMERIC_FIELDS = Object.freeze([ "words", "readNum", "commentNum", "floorNum" ]); SORT_FIELDS = Object.freeze([ "updateTimestamp", "createTimestamp", "lastReadTimestamp", ...NUMERIC_FIELDS, "title", "authorName" ]); DEFAULT_SEARCH = Object.freeze({ query: "", keywordConditions: [], excludeKeywordConditions: [], authorQuery: "", authorConditions: [], excludeAuthorConditions: [], tag: "", tagConditions: [], excludeTagConditions: [], type: "all", minWords: null, maxWords: null, minReadNum: null, maxReadNum: null, minCommentNum: null, maxCommentNum: null, minFloorNum: null, maxFloorNum: null, sortBy: "updateTimestamp", sortDirection: "desc", mode: "sort", randomSeed: 1, page: 1, pageSize: 20, collectionMode: "any", historyMode: "any", publishTimeMode: "recent", publishRecentValue: "", publishRecentUnit: "days", publishStart: "", publishEnd: "", updateTimeMode: "recent", updateRecentValue: "", updateRecentUnit: "days", updateStart: "", updateEnd: "" }); UNIT_MILLISECONDS = Object.freeze({ hours: 36e5, days: 864e5, weeks: 7 * 864e5, months: 30 * 864e5 }); } }); // src/recommender.js function list(value) { return Array.isArray(value) ? value : []; } function text(value) { return value == null ? "" : String(value).trim(); } function number(value) { const result = Number(value); return Number.isFinite(result) ? result : 0; } function tagName(tag) { return text(typeof tag === "string" ? tag : tag?.name ?? tag?.tagName); } function normalizeCandidate(raw, source = "unknown") { const id = text(raw.opusId ?? raw.bookId ?? raw.id); const author = raw.author && typeof raw.author === "object" ? raw.author : {}; return { id, title: text(raw.opusName ?? raw.title ?? raw.name) || "\u672A\u547D\u540D\u4F5C\u54C1", authorId: text(raw.userId ?? raw.authorId ?? author.userId ?? author.id), authorName: text(raw.nickName ?? raw.authorName ?? author.nickName ?? author.name) || "\u672A\u77E5\u4F5C\u8005", tags: list(raw.tags ?? raw.tagList).map(tagName).filter(Boolean), updateTime: text(raw.updateTime ?? raw.updatedAt ?? raw.createTime), updateTimestamp: number(raw.updateTimestamp), createTime: text(raw.createTime), cover: text(raw.opusCoverPic ?? raw.coverPic ?? raw.cover ?? raw.firstImg), summary: text(raw.oneLineText ?? raw.introduction ?? raw.description ?? raw.text), readNum: number(raw.readNum), commentNum: number(raw.commentNum), collectNum: number(raw.collectNum), goodNum: number(raw.goodNum), words: number(raw.words), floorNum: number(raw.floorNum), type: text(raw.type) || "book", enable: number(raw.enable), source, url: id ? `https://www.gululu.world/book/${encodeURIComponent(id)}` : "" }; } var DEFAULT_SETTINGS; var init_recommender = __esm({ "src/recommender.js"() { DEFAULT_SETTINGS = Object.freeze({ preferredTags: [], blockedTags: [], preferredAuthors: [], blockedAuthors: [], seenPenalty: 35, maxPerAuthor: 2, resultLimit: 20, minimumInteractions: 10 }); } }); // src/sync-policy.js function parseSiteTime(value) { if (!value) return NaN; const timestamp = Date.parse(String(value).trim().replace(" ", "T")); return Number.isFinite(timestamp) ? timestamp : NaN; } function calculateCutoff(lastSuccessfulSync, overlapMinutes = DEFAULT_OVERLAP_MINUTES) { return Math.max(0, Number(lastSuccessfulSync) - overlapMinutes * 6e4); } function pageTimeRange(items) { const timestamps = items.map((item) => parseSiteTime(item.updateTime)).filter(Number.isFinite); return { min: timestamps.length ? Math.min(...timestamps) : NaN, max: timestamps.length ? Math.max(...timestamps) : NaN, validCount: timestamps.length }; } function updateStalePageCount(currentCount, range, cutoff) { if (!range.validCount || !Number.isFinite(range.max)) return 0; return range.max < cutoff ? currentCount + 1 : 0; } function shouldContinueFullSync({ received }) { return received > 0; } var DEFAULT_OVERLAP_MINUTES, REQUIRED_STALE_PAGES; var init_sync_policy = __esm({ "src/sync-policy.js"() { DEFAULT_OVERLAP_MINUTES = 30; REQUIRED_STALE_PAGES = 3; } }); // src/synchronizer.js async function fetchPage(page, signal) { const query = new URLSearchParams({ pageNum: String(page), pageSize: String(PAGE_SIZE), isAsc: "asc", sort: "1" }); let lastError; for (let attempt = 1; attempt <= MAX_RETRIES; attempt += 1) { try { const response = await fetch(`${API_URL}?${query}`, { headers: { platform: "1" }, cache: "no-store", signal }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const payload = await response.json(); if (payload.code !== 200) throw new Error(payload.msg || `\u4E1A\u52A1\u72B6\u6001 ${payload.code}`); return { rows: Array.isArray(payload.rows) ? payload.rows : [], total: Number.isFinite(Number(payload.total)) ? Number(payload.total) : null }; } catch (error) { if (error.name === "AbortError") throw error; lastError = error; if (attempt < MAX_RETRIES) await wait(1e3 * 2 ** (attempt - 1), signal); } } throw lastError; } function storedWork(raw, mode, runId, syncedAt) { const work = normalizeCandidate(raw, "\u672C\u5730\u7D22\u5F15"); return { ...work, updateTimestamp: parseSiteTime(raw.updateTime), createTimestamp: parseSiteTime(raw.createTime), syncedAt, lastFullSyncRun: mode === "full" ? runId : void 0 }; } async function getSyncState() { return { count: await countWorks(), lastSuccessfulSync: await getMeta("lastSuccessfulSync") ?? null, lastFullSync: await getMeta("lastFullSync") ?? null, status: await getMeta("syncStatus") ?? { state: "idle" } }; } async function synchronize({ mode, signal, onProgress = () => { } }) { if (!["full", "incremental"].includes(mode)) throw new Error("\u672A\u77E5\u540C\u6B65\u6A21\u5F0F"); const previousWatermark = await getMeta("lastSuccessfulSync"); if (mode === "incremental" && !previousWatermark) throw new Error("\u8BF7\u5148\u5B8C\u6210\u4E00\u6B21\u5168\u91CF\u540C\u6B65"); const startedAt = Date.now(); const runId = `${startedAt}-${Math.random().toString(36).slice(2)}`; const cutoff = mode === "incremental" ? calculateCutoff(previousWatermark) : 0; let page = 1; let downloaded = 0; let stored = 0; let stalePages = 0; let expectedTotal = null; const initialStatus = { state: "running", mode, page, downloaded, stored, startedAt, cutoff }; await setMeta("syncStatus", initialStatus); onProgress(initialStatus); try { while (true) { if (signal?.aborted) throw new DOMException("\u540C\u6B65\u5DF2\u53D6\u6D88", "AbortError"); const { rows, total } = await fetchPage(page, signal); if (total != null) expectedTotal = total; downloaded += rows.length; const range = pageTimeRange(rows); const selected = mode === "full" ? rows : rows.filter((row) => { const timestamp = parseSiteTime(row.updateTime); return !Number.isFinite(timestamp) || timestamp >= cutoff; }); const syncedAt = Date.now(); await putWorksPreservingTags(selected.map((row) => storedWork(row, mode, runId, syncedAt))); stored += selected.length; const progress = { state: "running", mode, page, downloaded, stored, startedAt, cutoff, expectedTotal, pageReceived: rows.length, pageMinTime: range.min, pageMaxTime: range.max }; await setMeta("syncStatus", progress); onProgress(progress); if (!rows.length) break; if (mode === "full") { if (!shouldContinueFullSync({ page, pageSize: PAGE_SIZE, total: expectedTotal, received: rows.length })) break; } else { stalePages = updateStalePageCount(stalePages, range, cutoff); if (stalePages >= REQUIRED_STALE_PAGES) break; } page += 1; await wait(REQUEST_INTERVAL_MS, signal); } if (mode === "full") { await removeWorksNotSeenInRun(runId); await setMeta("lastFullSync", startedAt); } await setMeta("lastSuccessfulSync", startedAt); const finalStatus = { state: "complete", mode, page, downloaded, stored, indexed: await countWorks(), startedAt, completedAt: Date.now(), expectedTotal }; await setMeta("syncStatus", finalStatus); onProgress(finalStatus); return finalStatus; } catch (error) { const finalStatus = { state: error.name === "AbortError" ? "cancelled" : "failed", mode, page, downloaded, stored, startedAt, endedAt: Date.now(), error: error.message }; await setMeta("syncStatus", finalStatus); onProgress(finalStatus); throw error; } } var API_URL, PAGE_SIZE, REQUEST_INTERVAL_MS, MAX_RETRIES, wait; var init_synchronizer = __esm({ "src/synchronizer.js"() { init_recommender(); init_database(); init_sync_policy(); API_URL = "https://backend.gululu.world/opus/list-sort"; PAGE_SIZE = 1e3; REQUEST_INTERVAL_MS = 1200; MAX_RETRIES = 3; wait = (milliseconds, signal) => new Promise((resolve, reject) => { const timer = setTimeout(resolve, milliseconds); signal?.addEventListener("abort", () => { clearTimeout(timer); reject(new DOMException("\u540C\u6B65\u5DF2\u53D6\u6D88", "AbortError")); }, { once: true }); }); } }); // src/user-data.js function extractWorkIds(payload, ids = /* @__PURE__ */ new Set()) { if (Array.isArray(payload)) { for (const item of payload) extractWorkIds(item, ids); } else if (payload && typeof payload === "object") { if (payload.opusId != null) ids.add(String(payload.opusId)); else if (payload.bookId != null && Number(payload.bookId) > 0) ids.add(String(payload.bookId)); else if (payload.historyId != null && Number(payload.historyId) > 0 && Number(payload.type) === 1) { ids.add(String(payload.historyId)); } for (const [key, value] of Object.entries(payload)) { if (!["opusId", "bookId", "historyId"].includes(key) && (Array.isArray(value) || value && typeof value === "object")) { extractWorkIds(value, ids); } } } return ids; } function parseReadTime(value) { if (value == null || value === "") return 0; const numeric = Number(value); if (Number.isFinite(numeric)) return numeric < 1e10 ? numeric * 1e3 : numeric; const parsed = Date.parse(String(value).replace(" ", "T")); return Number.isFinite(parsed) ? parsed : 0; } function extractHistoryData(payload, result = { ids: /* @__PURE__ */ new Set(), readTimes: /* @__PURE__ */ new Map() }) { if (Array.isArray(payload)) { for (const item of payload) extractHistoryData(item, result); } else if (payload && typeof payload === "object") { if (payload.historyId != null && Number(payload.historyId) > 0 && Number(payload.type) === 1) { const id = String(payload.historyId); result.ids.add(id); const timestamp = parseReadTime(payload.readTime); if (timestamp > (result.readTimes.get(id) ?? 0)) result.readTimes.set(id, timestamp); } for (const [key, value] of Object.entries(payload)) { if (key !== "historyId" && (Array.isArray(value) || value && typeof value === "object")) { extractHistoryData(value, result); } } } return result; } var init_user_data = __esm({ "src/user-data.js"() { } }); // src/background.js var background_exports = {}; async function loadTagIds(tagName2) { const tag = String(tagName2 ?? "").trim(); if (!tag) return null; const cached = tagCache.get(tag); if (cached && Date.now() - cached.createdAt < 10 * 6e4) return cached.ids; const ids = /* @__PURE__ */ new Set(); const pageSize = 1e3; for (let page = 1; page <= 200; page += 1) { const query = new URLSearchParams({ tagName: tag, pageNum: String(page), pageSize: String(pageSize) }); const response = await fetch(`https://backend.gululu.world/search/opus-tag?${query}`, { headers: { platform: "1" }, cache: "no-store" }); if (!response.ok) throw new Error(`\u6807\u7B7E\u63A5\u53E3 HTTP ${response.status}`); const rows = await response.json(); if (!Array.isArray(rows)) throw new Error("\u6807\u7B7E\u63A5\u53E3\u8FD4\u56DE\u683C\u5F0F\u5F02\u5E38"); if (!rows.length) break; const before = ids.size; for (const row of rows) if (row.opusId != null) ids.add(String(row.opusId)); if (ids.size === before) break; await new Promise((resolve) => setTimeout(resolve, 200)); } await applyCompleteTagIndex(tag, ids); tagCache.set(tag, { ids, createdAt: Date.now() }); return ids; } function union(sets) { return new Set(sets.flatMap((set) => [...set])); } function intersection(sets) { if (!sets.length) return /* @__PURE__ */ new Set(); return new Set([...sets[0]].filter((id) => sets.slice(1).every((set) => set.has(id)))); } async function resolveTagNode(node) { if (node?.type === "term") return loadTagIds(node.value); if (node?.type !== "group" || !node.children?.length) return /* @__PURE__ */ new Set(); const children = await Promise.all(node.children.map(resolveTagNode)); return node.operator === "AND" ? intersection(children) : union(children); } async function resolveTagForest(criteria) { const nodes = Array.isArray(criteria.tagConditions) ? criteria.tagConditions : []; if (!nodes.length && criteria.tag) return loadTagIds(criteria.tag); if (!nodes.length) return null; return union(await Promise.all(nodes.map(resolveTagNode))); } async function fetchUserIds(path, token, paginated = false) { if (!token) throw new Error("\u8BE5\u7B5B\u9009\u9700\u8981\u5148\u767B\u5F55\u9AA8\u788C\u788C"); const ids = /* @__PURE__ */ new Set(); const maxPages = paginated ? 20 : 1; for (let page = 1; page <= maxPages; page += 1) { const suffix = paginated ? `?pageNum=${page}&pageSize=1000` : ""; const response = await fetch(`https://backend.gululu.world${path}${suffix}`, { headers: { platform: "1", Authorization: `Bearer ${token}` }, cache: "no-store" }); if (!response.ok) throw new Error(`\u7528\u6237\u6570\u636E\u63A5\u53E3 HTTP ${response.status}`); const payload = await response.json(); if (payload?.code === 401) throw new Error("\u767B\u5F55\u72B6\u6001\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u9AA8\u788C\u788C"); const before = ids.size; extractWorkIds(payload, ids); const rows = Array.isArray(payload) ? payload : payload?.rows ?? payload?.data; if (!paginated || !Array.isArray(rows) || rows.length < 1e3 || ids.size === before) break; } return ids; } async function fetchHistory(token) { if (!token) throw new Error("\u8BE5\u7B5B\u9009\u9700\u8981\u5148\u767B\u5F55\u9AA8\u788C\u788C"); const response = await fetch("https://backend.gululu.world/history/get", { headers: { platform: "1", Authorization: `Bearer ${token}` }, cache: "no-store" }); if (!response.ok) throw new Error(`\u7528\u6237\u6570\u636E\u63A5\u53E3 HTTP ${response.status}`); const payload = await response.json(); if (payload?.code === 401) throw new Error("\u767B\u5F55\u72B6\u6001\u5DF2\u5931\u6548\uFF0C\u8BF7\u91CD\u65B0\u767B\u5F55\u9AA8\u788C\u788C"); return extractHistoryData(payload); } async function loadWorkTags(work) { if (work.tagsLoadedAt) return work; const response = await fetch(`https://backend.gululu.world/reader/opus/detail/${encodeURIComponent(work.id)}`, { headers: { platform: "1" }, cache: "no-store" }); if (!response.ok) throw new Error(`\u4F5C\u54C1\u6807\u7B7E\u63A5\u53E3 HTTP ${response.status}`); const payload = await response.json(); if (payload?.code !== 200) throw new Error(payload?.msg || `\u4F5C\u54C1\u6807\u7B7E\u4E1A\u52A1\u72B6\u6001 ${payload?.code}`); const tags = Array.isArray(payload?.data?.tags) ? payload.data.tags.map((tag) => String(tag?.tagName ?? tag?.name ?? "").trim()).filter(Boolean) : []; return { ...work, tags, tagsLoadedAt: Date.now() }; } async function enrichVisibleTags(items) { const enriched = [...items]; let cursor = 0; const workers = Array.from({ length: Math.min(4, items.length) }, async () => { while (cursor < items.length) { const index = cursor++; try { enriched[index] = await loadWorkTags(items[index]); } catch { enriched[index] = items[index]; } } }); await Promise.all(workers); const changed = enriched.filter((item, index) => item !== items[index]); if (changed.length) await putWorks(changed); return enriched; } async function searchLocalWorks(criteria, token) { const needsCollection = criteria.collectionMode && criteria.collectionMode !== "any"; const needsHistory = criteria.historyMode && criteria.historyMode !== "any"; const [candidates, tagIds, excludeTagIds, collectionIds, historyData] = await Promise.all([ getAllWorks(), resolveTagForest(criteria), resolveTagForest({ tagConditions: criteria.excludeTagConditions }), needsCollection ? fetchUserIds("/bookshelf/get-book", token, true) : null, needsHistory ? fetchHistory(token) : null ]); const result = searchWorks(candidates, criteria, { tagIds, excludeTagIds, collectionIds, historyIds: historyData?.ids, historyReadTimes: historyData?.readTimes }); result.items = await enrichVisibleTags(result.items); return { ...result, candidateCount: candidates.length }; } var activeSync, tagCache; var init_background = __esm({ "src/background.js"() { init_database(); init_search(); init_synchronizer(); init_user_data(); activeSync = null; tagCache = /* @__PURE__ */ new Map(); chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { let action; if (message?.type === "GULULU_SEARCH") { action = searchLocalWorks(message.criteria ?? {}, message.token); } else if (message?.type === "GULULU_SYNC_STATE") { action = getSyncState().then((state2) => { if (state2.status.state === "running" && !activeSync) { state2.status = { ...state2.status, state: "failed", error: "\u4E0A\u6B21\u540C\u6B65\u610F\u5916\u4E2D\u65AD\uFF0C\u8BF7\u91CD\u65B0\u5F00\u59CB" }; } return state2; }); } else { return false; } action.then((data) => sendResponse({ ok: true, ...data })).catch((error) => sendResponse({ ok: false, error: error.message })); return true; }); chrome.runtime.onConnect.addListener((port) => { if (port.name !== "gululu-sync") return; port.onMessage.addListener((message) => { if (message?.type === "CANCEL_SYNC") { activeSync?.controller.abort(); return; } if (message?.type !== "START_SYNC") return; if (activeSync) { port.postMessage({ state: "failed", error: "\u5DF2\u6709\u540C\u6B65\u4EFB\u52A1\u6B63\u5728\u8FD0\u884C" }); return; } const controller = new AbortController(); activeSync = { controller, port }; synchronize({ mode: message.mode, signal: controller.signal, onProgress: (progress) => port.postMessage(progress) }).catch(() => { }).finally(() => { activeSync = null; }); }); port.onDisconnect.addListener(() => { if (activeSync?.port === port) activeSync.controller.abort(); }); }); } }); // src/content.js var content_exports = {}; function element(tag, className, text2) { const node = document.createElement(tag); if (className) node.className = className; if (text2 != null) node.textContent = text2; return node; } function createInput(label, key, { type = "number", placeholder = "\u4E0D\u9650" } = {}) { const wrapper = element("label", "ger-field"); wrapper.append(element("span", "ger-label", label)); const input = element("input", "ger-input"); input.type = type; input.name = key; input.dataset.criteriaKey = key; input.value = state.criteria[key] ?? ""; input.placeholder = placeholder; if (type === "number") input.min = "0"; wrapper.append(input); return wrapper; } function createSelect(label, key, options) { const wrapper = element("label", "ger-field"); wrapper.append(element("span", "ger-label", label)); const select = element("select", "ger-input"); select.name = key; select.dataset.criteriaKey = key; for (const [value, text2] of options) { const option = element("option", "", text2); option.value = value; option.selected = String(state.criteria[key]) === value; select.append(option); } wrapper.append(select); return wrapper; } function createRange(label, minimumKey, maximumKey) { const group = element("fieldset", "ger-range"); group.append(element("legend", "ger-label", label)); const inputs = element("div", "ger-range-inputs"); inputs.append(createInput("\u6700\u5C0F", minimumKey), createInput("\u6700\u5927", maximumKey)); group.append(inputs); return group; } function createTimeFilter(label, prefix) { const section = element("fieldset", "ger-time-filter"); section.append(element("legend", "ger-label", label)); const modeField = createSelect("\u68C0\u7D22\u65B9\u5F0F", `${prefix}TimeMode`, [["recent", "\u6700\u8FD1\u4E00\u6BB5\u65F6\u95F4"], ["range", "\u8D77\u6B62\u65F6\u95F4"]]); const recent = element("div", "ger-time-mode"); recent.append( createInput("\u65F6\u95F4\u957F\u5EA6", `${prefix}RecentValue`, { placeholder: "\u7559\u7A7A\u8868\u793A\u4E0D\u9650" }), createSelect("\u5355\u4F4D", `${prefix}RecentUnit`, [["hours", "\u5C0F\u65F6"], ["days", "\u5929"], ["weeks", "\u5468"], ["months", "\u6708\uFF0830\u5929\uFF09"]]) ); const range = element("div", "ger-time-mode"); range.append( createInput("\u5F00\u59CB\u65F6\u95F4", `${prefix}Start`, { type: "datetime-local", placeholder: "" }), createInput("\u7ED3\u675F\u65F6\u95F4", `${prefix}End`, { type: "datetime-local", placeholder: "" }) ); const select = modeField.querySelector("select"); const updateVisibility = () => { const isRecent = select.value === "recent"; recent.hidden = !isRecent; range.hidden = isRecent; }; select.addEventListener("change", updateVisibility); section.append(modeField, recent, range); updateVisibility(); return section; } function readCriteria(form) { const data = new FormData(form); const criteria = { ...state.criteria }; for (const key of Object.keys(criteria)) { const value = data.get(key); if (value != null) criteria[key] = value; } criteria.type = "all"; criteria.pageSize = Math.min(100, Math.max(1, Number(criteria.pageSize) || 20)); return criteria; } function refreshActiveCriteria(form) { for (const control of form.querySelectorAll("[data-criteria-key]")) { const key = control.dataset.criteriaKey; const value = String(control.value ?? ""); const defaultValue = String(DEFAULT_SEARCH2[key] ?? ""); control.classList.toggle("is-active", value !== "" && value !== defaultValue); } for (const group of form.querySelectorAll(".ger-range, .ger-time-filter")) { group.classList.toggle("is-active", Boolean(group.querySelector(".ger-input.is-active"))); } for (const group of form.querySelectorAll(".ger-filter-group")) { const hasActiveControl = Boolean(group.querySelector(".ger-input.is-active")); const hasCondition = Boolean(group.querySelector(".ger-condition-chip")); group.classList.toggle("is-active", hasActiveControl || hasCondition); } } function conditionLabel(node) { if (node?.type === "term") return node.value; return `(${(node?.children ?? []).map(conditionLabel).join(` ${node.operator} `)})`; } function mergeConditions(nodes, indices, operator) { const selected = [...indices].sort((a, b) => a - b); if (selected.length < 2) return nodes; const selectedSet = new Set(selected); const group = { type: "group", operator, children: selected.map((index) => nodes[index]) }; return nodes.flatMap((node, index) => index === selected[0] ? [group] : selectedSet.has(index) ? [] : [node]); } function createConditionEditor(label, key, placeholder, { warning = "" } = {}) { const section = element("section", "ger-condition-editor"); if (key.startsWith("exclude")) section.classList.add("is-negative"); const heading = element("div", "ger-condition-heading"); heading.append(element("span", "ger-label", label)); if (warning) { const badge = element("span", "ger-warning-badge", warning); badge.title = "\u8BE5\u529F\u80FD\u4F9D\u8D56\u7AD9\u70B9\u6807\u7B7E\u63A5\u53E3\uFF0C\u4E0D\u80FD\u4FDD\u8BC1\u7ED3\u679C\u7EDD\u5BF9\u5B8C\u6574"; heading.append(badge); } section.append(heading); const chips = element("div", "ger-condition-chips"); const inputRow = element("div", "ger-condition-input-row"); const input = element("input", "ger-input"); input.type = "search"; input.placeholder = placeholder; const hint = element("span", "ger-condition-hint", "\u56DE\u8F66\u6DFB\u52A0\uFF1B\u672A\u5408\u5E76\u9879\u9ED8\u8BA4 OR"); inputRow.append(input, hint); const controls = element("div", "ger-condition-controls"); const mergeAnd = element("button", "ger-mini-button", "AND \u5408\u5E76"); const mergeOr = element("button", "ger-mini-button", "OR \u5408\u5E76"); mergeAnd.type = mergeOr.type = "button"; const selected = /* @__PURE__ */ new Set(); function render() { chips.replaceChildren(); const nodes = Array.isArray(state.criteria[key]) ? state.criteria[key] : []; nodes.forEach((node, index) => { const chip = element("div", `ger-condition-chip${selected.has(index) ? " is-selected" : ""}`); const select = element("button", "ger-chip-label", conditionLabel(node)); select.type = "button"; select.setAttribute("aria-pressed", String(selected.has(index))); select.title = "\u70B9\u51FB\u9009\u62E9\uFF0C\u4EE5\u4FBF\u8FDB\u884C AND/OR \u5408\u5E76"; select.addEventListener("click", () => { selected.has(index) ? selected.delete(index) : selected.add(index); render(); }); chip.append(select); if (node.type === "group") { const split = element("button", "ger-chip-action", "\u62C6"); split.type = "button"; split.title = "\u62C6\u5206\u7EC4\u5408"; split.addEventListener("click", () => { state.criteria[key] = [...nodes.slice(0, index), ...node.children, ...nodes.slice(index + 1)]; selected.clear(); render(); }); chip.append(split); } const remove = element("button", "ger-chip-action", "\xD7"); remove.type = "button"; remove.title = "\u5220\u9664\u6761\u4EF6"; remove.addEventListener("click", () => { state.criteria[key] = nodes.filter((_, position) => position !== index); selected.clear(); render(); }); chip.append(remove); chips.append(chip); }); mergeAnd.disabled = mergeOr.disabled = selected.size < 2; controls.hidden = nodes.length < 2; const form = section.closest("form"); if (form) refreshActiveCriteria(form); } function merge(operator) { state.criteria[key] = mergeConditions(state.criteria[key] ?? [], selected, operator); selected.clear(); render(); } mergeAnd.addEventListener("click", () => merge("AND")); mergeOr.addEventListener("click", () => merge("OR")); controls.append(mergeAnd, mergeOr); input.addEventListener("keydown", (event) => { if (event.key !== "Enter") return; event.preventDefault(); const value = input.value.trim(); if (!value) return; state.criteria[key] = [...state.criteria[key] ?? [], { type: "term", value }]; input.value = ""; render(); }); section.append(chips, inputRow, controls); render(); return section; } function formatNumber(value) { return Number(value || 0).toLocaleString("zh-CN"); } function renderCard(item) { const card = element("article", "ger-card"); if (item.cover) { const image = element("img", "ger-cover"); image.src = item.cover; image.alt = ""; image.loading = "lazy"; card.append(image); } const body = element("div", "ger-card-body"); const title = element("a", "ger-title", item.title); title.href = item.url; title.target = "_blank"; title.rel = "noopener noreferrer"; body.append(title); body.append(element("div", "ger-meta", `${item.authorName} \xB7 ${item.type === "game" ? "\u4E92\u52A8\u4F5C\u54C1" : "\u4F5C\u54C1"}`)); body.append(element("div", "ger-meta", `\u53D1\u5E03 ${item.createTime || "\u672A\u77E5"} \xB7 \u66F4\u65B0 ${item.updateTime || "\u672A\u77E5"}`)); if (item.summary) body.append(element("p", "ger-summary", item.summary)); body.append(element("div", "ger-stats", `\u5B57\u6570 ${formatNumber(item.words)} \xB7 \u697C\u5C42 ${formatNumber(item.floorNum)} \xB7 \u9605\u8BFB ${formatNumber(item.readNum)}`)); body.append(element("div", "ger-stats", `\u8BC4\u8BBA ${formatNumber(item.commentNum)}`)); if (Array.isArray(item.tags) && item.tags.length) { const tags = element("div", "ger-tags"); tags.setAttribute("aria-label", "\u4F5C\u54C1\u6807\u7B7E"); for (const tag of item.tags) tags.append(element("span", "ger-tag", tag)); body.append(tags); } card.append(body); return card; } function formatTime(timestamp) { return timestamp ? new Date(timestamp).toLocaleString("zh-CN") : "\u5C1A\u672A\u5B8C\u6210\u540C\u6B65"; } function renderSyncState(panel, syncState) { const text2 = panel.querySelector(".ger-sync-status"); const incremental = panel.querySelector('[data-sync-mode="incremental"]'); const cancel = panel.querySelector(".ger-sync-cancel"); const running = syncState.state === "running"; incremental.disabled = running || !syncState.lastSuccessfulSync; panel.querySelector('[data-sync-mode="full"]').disabled = running; cancel.hidden = !running; if (running) { text2.textContent = `${syncState.mode === "full" ? "\u5168\u91CF" : "\u589E\u91CF"}\u540C\u6B65\u4E2D\uFF1A\u7B2C ${syncState.page} \u9875\uFF0C\u4E0B\u8F7D ${syncState.downloaded} \u6761\uFF0C\u5199\u5165 ${syncState.stored} \u6761`; } else { text2.textContent = `\u672C\u5730\u7D22\u5F15 ${syncState.count ?? syncState.indexed ?? 0} \u6761\uFF1B\u4E0A\u6B21\u6210\u529F\uFF1A${formatTime(syncState.lastSuccessfulSync)}`; if (syncState.state === "failed") text2.textContent += `\uFF1B\u4E0A\u6B21\u5931\u8D25\uFF1A${syncState.error}`; if (syncState.state === "cancelled") text2.textContent += "\uFF1B\u4E0A\u6B21\u540C\u6B65\u5DF2\u53D6\u6D88"; } } async function loadSyncState(panel) { const response = await sendMessage({ type: "GULULU_SYNC_STATE" }); if (!response?.ok) throw new Error(response?.error || "\u65E0\u6CD5\u8BFB\u53D6\u540C\u6B65\u72B6\u6001"); renderSyncState(panel, { ...response.status, ...response }); } function startSync(panel, mode) { if (syncPort) return; syncPort = chrome.runtime.connect({ name: "gululu-sync" }); syncPort.onMessage.addListener(async (progress) => { renderSyncState(panel, progress); if (["complete", "failed", "cancelled"].includes(progress.state)) { syncPort.disconnect(); syncPort = null; await loadSyncState(panel).catch(() => { }); if (progress.state === "complete") runSearch(panel); } }); syncPort.onDisconnect.addListener(() => { syncPort = null; }); syncPort.postMessage({ type: "START_SYNC", mode }); } function createSyncPanel(panel) { const section = element("section", "ger-sync"); const copy = element("div"); copy.append(element("h3", "ger-sync-heading", "\u672C\u5730\u5019\u9009\u6C60")); copy.append(element("div", "ger-sync-status", "\u6B63\u5728\u8BFB\u53D6\u672C\u5730\u7D22\u5F15\u2026")); const actions = element("div", "ger-sync-actions"); const full = element("button", "ger-button", "\u5168\u91CF\u540C\u6B65"); full.type = "button"; full.dataset.syncMode = "full"; full.addEventListener("click", () => startSync(panel, "full")); const incremental = element("button", "ger-button ger-primary", "\u540C\u6B65\u5230\u6700\u65B0"); incremental.type = "button"; incremental.dataset.syncMode = "incremental"; incremental.disabled = true; incremental.addEventListener("click", () => startSync(panel, "incremental")); const cancel = element("button", "ger-button ger-sync-cancel", "\u505C\u6B62"); cancel.type = "button"; cancel.hidden = true; cancel.addEventListener("click", () => syncPort?.postMessage({ type: "CANCEL_SYNC" })); actions.append(full, incremental, cancel); section.append(copy, actions); return section; } async function runSearch(panel) { if (state.loading) return; state.loading = true; const status = panel.querySelector(".ger-status"); const results = panel.querySelector(".ger-results"); const loadingOverlay = panel.querySelector(".ger-loading-overlay"); loadingOverlay.hidden = false; panel.setAttribute("aria-busy", "true"); status.textContent = "\u6B63\u5728\u68C0\u7D22\u672C\u5730\u7D22\u5F15\u2026"; try { const needsLogin = state.criteria.collectionMode !== "any" || state.criteria.historyMode !== "any"; const token = needsLogin ? readCookie("token") : ""; const response = await sendMessage({ type: "GULULU_SEARCH", criteria: state.criteria, token }); if (!response?.ok) throw new Error(response?.error || "\u672A\u77E5\u9519\u8BEF"); if (!response.candidateCount) { status.textContent = "\u672C\u5730\u5019\u9009\u6C60\u4E3A\u7A7A\uFF0C\u8BF7\u5148\u70B9\u51FB\u201C\u5168\u91CF\u540C\u6B65\u201D\u3002"; } else { status.textContent = `\u672C\u5730\u5171 ${response.candidateCount} \u6761\uFF1B\u5339\u914D ${response.total} \u6761\uFF1B\u7B2C ${response.page}/${response.totalPages} \u9875`; } results.replaceChildren(); panel.querySelector(".ger-pagination").replaceChildren(); for (const item of response.items) results.append(renderCard(item)); if (response.candidateCount && !response.items.length) results.append(element("div", "ger-empty", "\u6CA1\u6709\u4F5C\u54C1\u7B26\u5408\u5F53\u524D\u6761\u4EF6\u3002")); renderPagination(panel, response); } catch (error) { status.textContent = `\u68C0\u7D22\u5931\u8D25\uFF1A${error.message}`; } finally { state.loading = false; loadingOverlay.hidden = true; panel.setAttribute("aria-busy", "false"); } } function readCookie(name) { const prefix = `${encodeURIComponent(name)}=`; const entry = document.cookie.split("; ").find((part) => part.startsWith(prefix)); return entry ? decodeURIComponent(entry.slice(prefix.length)) : ""; } function renderPagination(panel, response) { const pager = panel.querySelector(".ger-pagination"); pager.replaceChildren(); if (!response.total || response.totalPages <= 1) return; const previous = element("button", "ger-button", "\u4E0A\u4E00\u9875"); previous.type = "button"; previous.disabled = response.page <= 1; previous.addEventListener("click", () => { state.criteria.page = response.page - 1; runSearch(panel); }); const label = element("span", "ger-page-label", `${response.page} / ${response.totalPages}`); const next = element("button", "ger-button", "\u4E0B\u4E00\u9875"); next.type = "button"; next.disabled = response.page >= response.totalPages; next.addEventListener("click", () => { state.criteria.page = response.page + 1; runSearch(panel); }); pager.append(previous, label, next); } function createFilterGroup(title, children, { open = true, negative = false } = {}) { const details = element("details", "ger-filter-group"); details.open = open; if (negative) details.classList.add("is-negative"); details.append(element("summary", "ger-filter-group-title", title)); const content = element("div", "ger-filter-group-content"); content.append(...children); details.append(content); return details; } function createSearchForm(panel) { const form = element("form", "ger-form"); const historyField = createSelect("\u5386\u53F2\u8BB0\u5F55\u7B5B\u9009", "historyMode", [["any", "\u4E0D\u9650"], ["include", "\u53EA\u770B\u8BB0\u5F55\u5185\u4F5C\u54C1"], ["exclude", "\u4E0D\u770B\u8BB0\u5F55\u5185\u4F5C\u54C1"]]); const sortField = createSelect("\u6392\u5E8F\u6307\u6807", "sortBy", [ ["updateTimestamp", "\u66F4\u65B0\u65F6\u95F4"], ["createTimestamp", "\u521B\u5EFA\u65F6\u95F4"], ["lastReadTimestamp", "\u6700\u540E\u6D4F\u89C8\u65F6\u95F4"], ["words", "\u5B57\u6570"], ["readNum", "\u9605\u8BFB\u6570"], ["commentNum", "\u8BC4\u8BBA\u6570"], ["floorNum", "\u697C\u5C42\u6570"], ["title", "\u6807\u9898"], ["authorName", "\u4F5C\u8005"] ]); form.append( createFilterGroup("\u5173\u952E\u8BCD\u7B5B\u9009", [ createConditionEditor("\u5173\u952E\u8BCD\u6761\u4EF6\uFF08\u6807\u9898\u3001\u4F5C\u8005\u3001\u7B80\u4ECB\uFF09", "keywordConditions", "\u8F93\u5165\u5173\u952E\u8BCD\u540E\u6309\u56DE\u8F66"), createConditionEditor("\u4F5C\u8005\u6761\u4EF6", "authorConditions", "\u8F93\u5165\u4F5C\u8005\u6635\u79F0\u5173\u952E\u8BCD\u540E\u6309\u56DE\u8F66"), createConditionEditor("\u4F5C\u54C1\u6807\u7B7E\u6761\u4EF6", "tagConditions", "\u8F93\u5165\u6807\u7B7E\u540E\u6309\u56DE\u8F66", { warning: "\u53EF\u80FD\u6F0F\u68C0" }) ]), createFilterGroup("\u5173\u952E\u8BCD\u8D1F\u5411\u7B5B\u9009", [ createConditionEditor("\u6392\u9664\u5173\u952E\u8BCD", "excludeKeywordConditions", "\u547D\u4E2D\u8FD9\u4E9B\u6761\u4EF6\u7684\u4F5C\u54C1\u5C06\u88AB\u6392\u9664"), createConditionEditor("\u6392\u9664\u4F5C\u8005", "excludeAuthorConditions", "\u547D\u4E2D\u8FD9\u4E9B\u4F5C\u8005\u6761\u4EF6\u7684\u4F5C\u54C1\u5C06\u88AB\u6392\u9664"), createConditionEditor("\u6392\u9664\u4F5C\u54C1\u6807\u7B7E", "excludeTagConditions", "\u547D\u4E2D\u8FD9\u4E9B\u6807\u7B7E\u6761\u4EF6\u7684\u4F5C\u54C1\u5C06\u88AB\u6392\u9664", { warning: "\u53EF\u80FD\u6F0F\u68C0" }) ], { open: false, negative: true }), createFilterGroup("\u5185\u5BB9\u7B5B\u9009", [ createSelect("\u6536\u85CF\u7B5B\u9009", "collectionMode", [["any", "\u4E0D\u9650"], ["include", "\u53EA\u770B\u6536\u85CF"], ["exclude", "\u4E0D\u770B\u6536\u85CF"]]), historyField ]), createFilterGroup("\u6570\u636E\u7B5B\u9009", [ createTimeFilter("\u53D1\u5E03\u65F6\u95F4", "publish"), createTimeFilter("\u6700\u540E\u66F4\u65B0\u65F6\u95F4", "update"), createRange("\u5B57\u6570", "minWords", "maxWords"), createRange("\u9605\u8BFB\u6570", "minReadNum", "maxReadNum"), createRange("\u8BC4\u8BBA\u6570", "minCommentNum", "maxCommentNum"), createRange("\u697C\u5C42\u6570", "minFloorNum", "maxFloorNum"), createSelect("\u6D4F\u89C8\u6A21\u5F0F", "mode", [["sort", "\u6392\u5E8F\u6A21\u5F0F"], ["random", "\u6761\u4EF6\u5185\u968F\u673A\u63A8\u9001"]]), sortField, createSelect("\u6392\u5E8F\u65B9\u5411", "sortDirection", [["desc", "\u4ECE\u9AD8\u5230\u4F4E / \u4ECE\u65B0\u5230\u65E7"], ["asc", "\u4ECE\u4F4E\u5230\u9AD8 / \u4ECE\u65E7\u5230\u65B0"]]), createSelect("\u6BCF\u9875\u6570\u91CF", "pageSize", [["10", "10 \u6761"], ["20", "20 \u6761"], ["50", "50 \u6761"], ["100", "100 \u6761"]]) ]) ); const actions = element("div", "ger-actions"); const submit = element("button", "ger-button ger-primary", "\u6267\u884C\u68C0\u7D22"); submit.type = "submit"; const reset = element("button", "ger-button", "\u91CD\u7F6E\u6761\u4EF6"); reset.type = "button"; reset.addEventListener("click", async () => { state.criteria = { ...DEFAULT_SEARCH2 }; await chrome.storage.local.set({ [STORAGE_KEY]: state.criteria }); const replacement = createSearchForm(panel); form.replaceWith(replacement); runSearch(panel); }); actions.append(submit, reset); form.append(actions); form.addEventListener("submit", async (event) => { event.preventDefault(); state.criteria = readCriteria(form); state.criteria.page = 1; if (state.criteria.mode === "random") state.criteria.randomSeed = Date.now(); await chrome.storage.local.set({ [STORAGE_KEY]: state.criteria }); runSearch(panel); }); form.addEventListener("input", () => refreshActiveCriteria(form)); form.addEventListener("change", () => refreshActiveCriteria(form)); const historySelect = historyField.querySelector("select"); const sortSelect = sortField.querySelector("select"); const lastReadOption = sortSelect.querySelector('option[value="lastReadTimestamp"]'); const updateHistorySort = () => { const enabled = historySelect.value === "include"; lastReadOption.hidden = !enabled; if (!enabled && sortSelect.value === "lastReadTimestamp") sortSelect.value = DEFAULT_SEARCH2.sortBy; refreshActiveCriteria(form); }; historySelect.addEventListener("change", updateHistorySort); updateHistorySort(); refreshActiveCriteria(form); return form; } function createPanel() { const panel = element("section", "ger-panel"); panel.id = "gululu-enhanced-recommendations"; const header = element("div", "ger-header"); const heading = element("div"); heading.append(element("h2", "ger-heading", "\u672C\u5730\u4F5C\u54C1\u68C0\u7D22")); heading.append(element("p", "ger-subtitle", "\u4F7F\u7528\u660E\u786E\u7684\u7B5B\u9009\u6761\u4EF6\u548C\u4F20\u7EDF\u6392\u5E8F\uFF0C\u4E0D\u4F7F\u7528\u79EF\u5206\u63A8\u8350\u3002")); const searchButton = element("button", "ger-button ger-primary", "\u6267\u884C\u68C0\u7D22"); searchButton.type = "button"; searchButton.addEventListener("click", () => panel.querySelector(".ger-form").requestSubmit()); const collapseButton = element("button", "ger-button ger-collapse-button", "\u6536\u8D77\u9762\u677F"); collapseButton.type = "button"; collapseButton.setAttribute("aria-expanded", "true"); header.append(heading, searchButton, collapseButton); const details = element("details", "ger-settings"); details.append(element("summary", "ger-settings-title", "\u68C0\u7D22\u4E0E\u6392\u5E8F\u6761\u4EF6")); details.append(createSearchForm(panel)); const body = element("div", "ger-panel-body"); body.append(createSyncPanel(panel), details, element("div", "ger-status", "\u51C6\u5907\u68C0\u7D22\u2026"), element("div", "ger-results"), element("nav", "ger-pagination")); const loadingOverlay = element("div", "ger-loading-overlay"); loadingOverlay.hidden = true; loadingOverlay.setAttribute("role", "status"); loadingOverlay.setAttribute("aria-live", "polite"); loadingOverlay.append(element("span", "ger-loading-spinner"), element("span", "ger-loading-text", "\u52A0\u8F7D\u4E2D")); collapseButton.addEventListener("click", () => { const collapsed = panel.classList.toggle("is-collapsed"); collapseButton.textContent = collapsed ? "\u5C55\u5F00\u9762\u677F" : "\u6536\u8D77\u9762\u677F"; collapseButton.setAttribute("aria-expanded", String(!collapsed)); }); panel.append(header, body, loadingOverlay); return panel; } async function start() { if (document.getElementById("gululu-enhanced-recommendations")) return; const stored = await chrome.storage.local.get([STORAGE_KEY]); state.criteria = { ...DEFAULT_SEARCH2, ...stored[STORAGE_KEY] ?? {}, query: "", tag: "", type: "all" }; for (const key of ["keywordConditions", "excludeKeywordConditions", "authorConditions", "excludeAuthorConditions", "tagConditions", "excludeTagConditions"]) { if (!Array.isArray(state.criteria[key])) state.criteria[key] = []; } const panel = createPanel(); const container = document.querySelector("#pageScrollContainer") || document.querySelector("main") || document.body; container.prepend(panel); await loadSyncState(panel); runSearch(panel); } var STORAGE_KEY, DEFAULT_SEARCH2, state, syncPort, sendMessage; var init_content = __esm({ "src/content.js"() { STORAGE_KEY = "gululuSearchCriteria"; DEFAULT_SEARCH2 = { query: "", keywordConditions: [], excludeKeywordConditions: [], authorQuery: "", authorConditions: [], excludeAuthorConditions: [], tag: "", tagConditions: [], excludeTagConditions: [], type: "all", minWords: "", maxWords: "", minReadNum: "", maxReadNum: "", minCommentNum: "", maxCommentNum: "", minFloorNum: "", maxFloorNum: "", sortBy: "updateTimestamp", sortDirection: "desc", mode: "sort", randomSeed: 1, page: 1, pageSize: 20, collectionMode: "any", historyMode: "any", publishTimeMode: "recent", publishRecentValue: "", publishRecentUnit: "days", publishStart: "", publishEnd: "", updateTimeMode: "recent", updateRecentValue: "", updateRecentUnit: "days", updateStart: "", updateEnd: "" }; state = { criteria: { ...DEFAULT_SEARCH2 }, loading: false }; syncPort = null; sendMessage = (message) => new Promise((resolve, reject) => { chrome.runtime.sendMessage(message, (response) => { if (chrome.runtime.lastError) reject(chrome.runtime.lastError); else resolve(response); }); }); start().catch((error) => console.error("\u9AA8\u788C\u788C\u672C\u5730\u68C0\u7D22\u542F\u52A8\u5931\u8D25", error)); } }); // src/styles.css var styles_default = '#gululu-enhanced-recommendations.ger-panel {\n position: relative;\n box-sizing: border-box;\n width: min(1180px, calc(100% - 32px));\n margin: 24px auto;\n padding: 22px;\n color: #252525;\n background: rgba(255, 255, 255, 0.96);\n border: 1px solid #e8e8e8;\n border-radius: 18px;\n box-shadow: 0 10px 32px rgba(0, 0, 0, 0.08);\n font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;\n}\n.ger-loading-overlay { position: absolute; z-index: 20; inset: 0; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 10px; color: #003f99; border-radius: 18px; background: rgba(238, 246, 255, .78); backdrop-filter: blur(2px); }\n.ger-loading-overlay[hidden] { display: none; }\n.ger-loading-spinner { width: 34px; height: 34px; border: 4px solid rgba(0, 107, 255, .2); border-top-color: #006bff; border-radius: 50%; animation: ger-spin .75s linear infinite; }\n.ger-loading-text { font-size: 15px; font-weight: 800; letter-spacing: .08em; }\n@keyframes ger-spin { to { transform: rotate(360deg); } }\n.ger-panel * { box-sizing: border-box; }\n.ger-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }\n.ger-panel.is-collapsed { padding-bottom: 16px; }\n.ger-panel.is-collapsed .ger-panel-body { display: none; }\n.ger-collapse-button { margin-left: -8px; }\n.ger-heading { margin: 0; font-size: 24px; }\n.ger-subtitle { margin: 4px 0 0; color: #707070; }\n.ger-button { padding: 8px 14px; border: 1px solid #d5d5d5; border-radius: 9px; background: #fff; cursor: pointer; }\n.ger-button:hover { border-color: #777; }\n.ger-primary { color: #fff; background: #242424; border-color: #242424; }\n.ger-button:disabled { cursor: not-allowed; opacity: 0.45; }\n.ger-sync { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 18px; padding: 14px; background: #f6f8f6; border-radius: 12px; }\n.ger-sync-heading { margin: 0 0 4px; font-size: 16px; }\n.ger-sync-status { color: #626862; font-size: 13px; }\n.ger-sync-actions { display: flex; flex-shrink: 0; gap: 8px; }\n.ger-settings { margin: 18px 0; border-top: 1px solid #eee; border-bottom: 1px solid #eee; }\n.ger-settings-title { padding: 12px 0; cursor: pointer; font-weight: 600; }\n.ger-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 18px; padding: 4px 0 18px; }\n.ger-filter-group { grid-column: 1 / -1; overflow: hidden; border: 1px solid #dfe3e7; border-radius: 12px; background: #fff; }\n.ger-filter-group.is-negative { border-color: #e7d8d2; background: #fdf9f7; }\n.ger-filter-group.is-active { border-color: #006bff; box-shadow: 0 0 0 2px rgba(0, 107, 255, .12); }\n.ger-filter-group-title { padding: 11px 14px; color: #303840; cursor: pointer; font-size: 14px; font-weight: 750; user-select: none; }\n.ger-filter-group-title:hover { color: #006bff; background: #f4f8ff; }\n.ger-filter-group.is-active > .ger-filter-group-title { color: #006bff; background: #eef6ff; }\n.ger-filter-group.is-negative > .ger-filter-group-title { color: #744638; }\n.ger-filter-group.is-negative > .ger-filter-group-title:hover { color: #9b3425; background: #fff3ef; }\n.ger-filter-group.is-negative.is-active > .ger-filter-group-title { color: #0057d1; background: #eef6ff; }\n.ger-filter-group-content { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px 18px; padding: 4px 14px 14px; border-top: 1px solid #edf0f2; }\n.ger-filter-group:not([open]) > .ger-filter-group-content { display: none; }\n.ger-field { display: grid; gap: 5px; }\n.ger-label { font-weight: 600; }\n.ger-input { width: 100%; min-width: 0; padding: 9px 10px; border: 1px solid #d7d7d7; border-radius: 8px; background: #fff; font: inherit; transition: background-color .15s, border-color .15s, box-shadow .15s; }\n.ger-input.is-active { color: #003f99; font-weight: 650; background: #eaf3ff; border-color: #006bff; box-shadow: 0 0 0 2px rgba(0, 107, 255, .16); }\n.ger-range { min-width: 0; margin: 0; padding: 0; border: 0; }\n.ger-range.is-active > .ger-label { color: #006bff; }\n.ger-range > .ger-label { margin-bottom: 5px; padding: 0; }\n.ger-range-inputs { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }\n.ger-range-inputs .ger-label { color: #777; font-size: 11px; font-weight: 400; }\n.ger-time-filter { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(150px, .7fr) 2fr; gap: 10px 14px; margin: 0; padding: 12px; border: 1px solid #dfe3e7; border-radius: 11px; background: #fafbfd; }\n.ger-time-filter.is-active { border-color: #006bff; box-shadow: 0 0 0 2px rgba(0, 107, 255, .12); }\n.ger-time-filter.is-active > legend { color: #006bff; }\n.ger-time-filter > legend { padding: 0 5px; }\n.ger-time-mode { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }\n.ger-time-mode[hidden] { display: none; }\n.ger-condition-editor { grid-column: 1 / -1; display: grid; gap: 8px; padding: 12px; border: 1px solid #dedfd9; border-radius: 11px; background: #fbfcf8; }\n.ger-condition-editor.is-negative { border-color: #e7d8d2; background: #fdf9f7; }\n.ger-condition-heading { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; }\n.ger-warning-badge { padding: 2px 7px; color: #8a3b00; border: 1px solid #f0ad63; border-radius: 999px; background: #fff3df; font-size: 11px; font-weight: 750; line-height: 1.4; }\n.ger-condition-chips { display: flex; flex-wrap: wrap; gap: 7px; min-height: 4px; }\n.ger-condition-chip { display: inline-flex; align-items: stretch; overflow: hidden; color: #fff; border: 1px solid #006bff; border-radius: 999px; background: #006bff; transition: background-color .15s, border-color .15s, box-shadow .15s, transform .15s; }\n.ger-condition-chip.is-selected { border-color: #003f99; background: #004fc4; box-shadow: 0 0 0 3px rgba(0,107,255,.24); transform: translateY(-1px); }\n.ger-chip-label, .ger-chip-action { border: 0; background: transparent; cursor: pointer; font: inherit; }\n.ger-chip-label { max-width: 520px; padding: 6px 10px 6px 12px; overflow: hidden; color: #fff; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }\n.ger-chip-action { padding: 4px 7px; color: rgba(255,255,255,.88); border-left: 1px solid rgba(255,255,255,.32); }\n.ger-chip-action:hover { color: #fff; background: rgba(0,0,0,.18); }\n.ger-condition-input-row { display: flex; align-items: center; gap: 10px; }\n.ger-condition-input-row .ger-input { max-width: 420px; }\n.ger-condition-hint { color: #777; font-size: 12px; }\n.ger-condition-controls { display: flex; gap: 6px; }\n.ger-mini-button { padding: 5px 9px; color: #315b3b; border: 1px solid #c7d2c4; border-radius: 7px; background: #f1f6ef; cursor: pointer; font-size: 12px; font-weight: 700; }\n.ger-mini-button:disabled { cursor: not-allowed; opacity: .4; }\n.ger-actions { display: flex; align-items: end; gap: 8px; }\n.ger-status { margin: 12px 0; color: #666; }\n.ger-results { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }\n.ger-card { display: flex; min-width: 0; overflow: hidden; border: 1px solid #ececec; border-radius: 13px; background: #fff; }\n.ger-cover { width: 126px; min-height: 168px; object-fit: cover; background: #f1f1f1; }\n.ger-card-body { display: flex; flex: 1; min-width: 0; padding: 14px; flex-direction: column; }\n.ger-title { display: block; color: #222; font-size: 17px; font-weight: 700; line-height: 1.45; overflow-wrap: anywhere; text-decoration: none; white-space: normal; word-break: break-word; }\n.ger-title:hover { text-decoration: underline; }\n.ger-meta, .ger-stats { margin-top: 6px; color: #747474; font-size: 12px; }\n.ger-tags { display: flex; flex-wrap: wrap; gap: 5px; margin-top: auto; padding-top: 10px; }\n.ger-tag { padding: 2px 7px; color: #0057d1; border: 1px solid #b9d5ff; border-radius: 999px; background: #eef6ff; font-size: 11px; font-weight: 650; line-height: 1.45; overflow-wrap: anywhere; }\n.ger-summary { display: -webkit-box; overflow: hidden; margin: 8px 0 0; color: #505050; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }\n.ger-reasons { margin: 9px 0 0; padding-left: 18px; color: #31643e; font-size: 12px; }\n.ger-empty { padding: 30px; color: #777; text-align: center; border: 1px dashed #ccc; border-radius: 12px; }\n.ger-pagination { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: 18px; }\n.ger-page-label { min-width: 72px; color: #666; text-align: center; }\n@media (max-width: 760px) {\n #gululu-enhanced-recommendations.ger-panel { width: calc(100% - 12px); margin: 10px auto; padding: 12px; border-radius: 14px; }\n .ger-loading-overlay { border-radius: 14px; }\n .ger-form, .ger-filter-group-content, .ger-results { grid-template-columns: 1fr; }\n .ger-header { display: grid; grid-template-columns: 1fr 1fr; align-items: stretch; gap: 9px; }\n .ger-header > :first-child { grid-column: 1 / -1; }\n .ger-heading { font-size: 20px; }\n .ger-subtitle { font-size: 12px; }\n .ger-header > .ger-button { width: 100%; min-height: 42px; }\n .ger-collapse-button { margin-left: 0; }\n .ger-sync { align-items: flex-start; flex-direction: column; }\n .ger-sync-actions { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); }\n .ger-sync-actions .ger-button { width: 100%; min-height: 42px; padding-inline: 8px; }\n .ger-settings { margin: 14px 0; }\n .ger-filter-group-content { gap: 10px; padding: 4px 10px 11px; }\n .ger-condition-editor, .ger-time-filter { padding: 10px; }\n .ger-condition-input-row { align-items: stretch; flex-direction: column; }\n .ger-condition-input-row .ger-input { max-width: none; }\n .ger-input { min-height: 42px; font-size: 16px; }\n .ger-condition-hint { line-height: 1.4; }\n .ger-chip-label { max-width: calc(100vw - 125px); }\n .ger-condition-controls { flex-wrap: wrap; }\n .ger-time-filter, .ger-time-mode { grid-template-columns: 1fr; }\n .ger-actions { grid-column: 1 / -1; }\n .ger-actions .ger-button { flex: 1; min-height: 42px; }\n .ger-results { gap: 10px; }\n .ger-cover { width: 88px; min-height: 132px; }\n .ger-card-body { padding: 10px; }\n .ger-title { font-size: 15px; }\n .ger-meta, .ger-stats { font-size: 11px; }\n .ger-pagination { gap: 8px; }\n .ger-pagination .ger-button { min-height: 42px; }\n .ger-page-label { min-width: 58px; }\n}\n'; // userscript/entry.js GM_addStyle(styles_default); function gmFetch(url, options = {}) { return new Promise((resolve, reject) => { const request = GM_xmlhttpRequest({ method: options.method ?? "GET", url: String(url), headers: options.headers ?? {}, data: options.body, onload(response) { resolve({ ok: response.status >= 200 && response.status < 300, status: response.status, json: async () => JSON.parse(response.responseText), text: async () => response.responseText }); }, onerror: () => reject(new Error("\u7F51\u7EDC\u8BF7\u6C42\u5931\u8D25")), ontimeout: () => reject(new Error("\u7F51\u7EDC\u8BF7\u6C42\u8D85\u65F6")) }); if (options.signal) { if (options.signal.aborted) request.abort(); else options.signal.addEventListener("abort", () => request.abort(), { once: true }); } }); } var messageListeners = []; var connectListeners = []; function eventList(list2) { return { addListener(listener) { list2.push(listener); }, removeListener(listener) { const index = list2.indexOf(listener); if (index >= 0) list2.splice(index, 1); } }; } function createPort(name) { const messageHandlers = []; const disconnectHandlers = []; return { name, onMessage: eventList(messageHandlers), onDisconnect: eventList(disconnectHandlers), postMessage(message) { queueMicrotask(() => messageHandlers.slice().forEach((handler) => handler(message))); }, disconnect() { queueMicrotask(() => disconnectHandlers.slice().forEach((handler) => handler())); } }; } globalThis.fetch = gmFetch; globalThis.chrome = { storage: { local: { async get(keys) { const requested = Array.isArray(keys) ? keys : [keys]; return Object.fromEntries(requested.map((key) => [key, GM_getValue(key)]).filter(([, value]) => value !== void 0)); }, async set(values) { for (const [key, value] of Object.entries(values)) GM_setValue(key, value); } } }, runtime: { lastError: null, onMessage: eventList(messageListeners), onConnect: eventList(connectListeners), sendMessage(message, callback) { let answered = false; const respond = (value) => { if (answered) return; answered = true; callback?.(value); }; for (const listener of messageListeners.slice()) listener(message, {}, respond); if (!messageListeners.length) respond(void 0); }, connect({ name }) { const port = createPort(name); queueMicrotask(() => connectListeners.slice().forEach((listener) => listener(port))); return port; } } }; async function startUserscript() { await Promise.resolve().then(() => (init_background(), background_exports)); await Promise.resolve().then(() => (init_content(), content_exports)); } startUserscript().catch((error) => console.error("\u9AA8\u788C\u788C\u672C\u5730\u4F5C\u54C1\u68C0\u7D22\u542F\u52A8\u5931\u8D25", error)); })();