Skip to content

Commit ca1f8a2

Browse files
feat(fufugal-rating-filter): 新增 fufugal.com 评分过滤脚本
- 浮动面板(floating-panel)提供开关 + 评分阈值滑动条(0-10,0.1 精度) - 配置持久化到 localStorage,开启时隐藏低于阈值的卡片 - 「暂无评分」按 0 分处理,一并隐藏 - 每次渲染重新读取评分(不缓存),修复 Vue 列表节点复用导致的过滤错乱 - 快捷键 [ 上一页、] 下一页,翻页后回到顶部 - MutationObserver 跟进 SPA 懒加载的新卡片
1 parent ac9f4b1 commit ca1f8a2

7 files changed

Lines changed: 372 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Fufugal 评分过滤
2+
3+
[fufugal.com](https://fufugal.com)(我的Galgame资源发布站)按评分阈值过滤卡片的油猴脚本。
4+
5+
## 功能
6+
7+
- 浮动面板(可拖拽 / 调整大小):
8+
- **开关**:启用 / 关闭过滤
9+
- **滑动条**:调节评分阈值,0–10,十分之一(0.1)精度
10+
- 配置持久化到 `localStorage`
11+
- 开启后,评分**低于阈值**的卡片会被隐藏
12+
- 「暂无评分」按 0 分处理,开启时一并隐藏
13+
- 每次渲染重新读取评分,不缓存,确保 SPA 列表重渲染时结果正确
14+
- 监听页面动态加载(SPA / 懒加载),新出现的卡片自动应用过滤
15+
- 快捷键 <code>[</code> 上一页、<code>]</code> 下一页(焦点在输入框时不触发),翻页后自动回到顶部,便于快速网罗游戏
16+
17+
## 实现要点
18+
19+
- 卡片选择器:`div.upDate`
20+
- 评分定位:卡片内星形图标(SVG `path``M283.84 867.84` 开头)后的 `<a>`,文本形如 `7.7分` / `4分` / `暂无评分`
21+
- 隐藏方式:`card.style.display = 'none'`,关闭过滤时恢复
22+
23+
## 配置存储
24+
25+
键:`fufugal-rating-filter`,值:`{ "enabled": boolean, "threshold": number }`
26+
27+
## 更新日志
28+
29+
{{changelog}}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### v0.1.0
2+
3+
- 首个版本:浮动面板提供开关与评分阈值滑动条(0–10,0.1 精度),按阈值隐藏低分卡片,配置持久化到 localStorage
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
### v0.2.0
2+
3+
- 修复 Vue 列表重渲染时复用 DOM 节点导致的评分缓存错乱(低分卡片在高阈值下未被隐藏):改为每次渲染重新读取评分,不缓存
4+
- 「暂无评分」改为按 0 分处理,开启过滤时一并隐藏
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
### v0.3.0
2+
3+
- 新增快捷键翻页:`[` 上一页、`]` 下一页(焦点在输入框时不触发),翻页后自动回到顶部
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
export interface FilterConfig {
2+
/** 是否启用过滤 */
3+
enabled: boolean;
4+
/** 评分阈值,低于此值的卡片将被隐藏(十分之一精度) */
5+
threshold: number;
6+
}
7+
8+
const STORAGE_KEY = 'fufugal-rating-filter';
9+
10+
const DEFAULT_CONFIG: FilterConfig = {
11+
enabled: false,
12+
threshold: 7,
13+
};
14+
15+
export function loadConfig(): FilterConfig {
16+
try {
17+
const raw = localStorage.getItem(STORAGE_KEY);
18+
if (!raw) return { ...DEFAULT_CONFIG };
19+
const parsed = JSON.parse(raw) as Partial<FilterConfig>;
20+
return {
21+
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULT_CONFIG.enabled,
22+
threshold:
23+
typeof parsed.threshold === 'number' && Number.isFinite(parsed.threshold)
24+
? clampThreshold(parsed.threshold)
25+
: DEFAULT_CONFIG.threshold,
26+
};
27+
} catch {
28+
return { ...DEFAULT_CONFIG };
29+
}
30+
}
31+
32+
export function saveConfig(config: FilterConfig): void {
33+
try {
34+
localStorage.setItem(STORAGE_KEY, JSON.stringify(config));
35+
} catch {
36+
// 存储失败时静默忽略(如隐私模式禁用了 localStorage)
37+
}
38+
}
39+
40+
export function clampThreshold(value: number): number {
41+
const clamped = Math.min(10, Math.max(0, value));
42+
// 保持十分之一精度
43+
return Math.round(clamped * 10) / 10;
44+
}

src/fufugal-rating-filter/index.ts

Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
export {};
2+
3+
import { createFloatingPanel } from '@xlxz/components/floating-panel';
4+
import { createAnimatedSlider } from '@xlxz/components/animated-slider';
5+
import { loadConfig, saveConfig, clampThreshold, type FilterConfig } from './config';
6+
7+
// ─── 评分解析 ────────────────────────────────
8+
//
9+
// 卡片中评分位于「星形图标」之后的 <a>。星形图标的 SVG path 以下列特征串开头,
10+
// 用它定位评分元素,避免误匹配品牌/评论数等其它带 <a> 的图标。
11+
const STAR_PATH_PREFIX = 'M283.84 867.84';
12+
13+
const CARD_SELECTOR = 'div.upDate';
14+
15+
/**
16+
* 在卡片内找到评分文本元素。
17+
*/
18+
function findRatingAnchor(card: Element): HTMLAnchorElement | null {
19+
const paths = card.querySelectorAll('svg path[d]');
20+
for (const path of paths) {
21+
const d = path.getAttribute('d') ?? '';
22+
if (!d.startsWith(STAR_PATH_PREFIX)) continue;
23+
// 星形图标所在的 <span> 内,紧随图标的 <a> 即为评分
24+
const span = path.closest('span');
25+
const anchor = span?.querySelector('a');
26+
if (anchor) return anchor as HTMLAnchorElement;
27+
}
28+
return null;
29+
}
30+
31+
/**
32+
* 解析评分。「暂无评分」/无法解析时按 0 分处理。
33+
*/
34+
function parseRating(text: string): number {
35+
const t = text.trim();
36+
if (!t || t.includes('暂无')) return 0;
37+
const m = t.match(/-?\d+(?:\.\d+)?/);
38+
if (!m) return 0;
39+
const n = Number(m[0]);
40+
return Number.isFinite(n) ? n : 0;
41+
}
42+
43+
/**
44+
* 读取卡片当前评分。
45+
*
46+
* 注意:不缓存。fufugal 是 Vue SPA,列表重渲染时会复用 DOM 节点并替换其中的游戏内容,
47+
* 若把评分缓存在节点属性上,会读到「上一个游戏」的旧评分,导致过滤错乱
48+
* (表现为某些低分卡片在高阈值下反而没被隐藏)。因此每次都重新读取。
49+
*/
50+
function readRating(card: HTMLElement): number {
51+
const anchor = findRatingAnchor(card);
52+
return parseRating(anchor?.textContent ?? '');
53+
}
54+
55+
// ─── 过滤应用 ────────────────────────────────
56+
57+
let config: FilterConfig = loadConfig();
58+
59+
function shouldHide(rating: number): boolean {
60+
if (!config.enabled) return false;
61+
return rating < config.threshold;
62+
}
63+
64+
function applyToCard(card: HTMLElement): void {
65+
const rating = readRating(card);
66+
card.style.display = shouldHide(rating) ? 'none' : '';
67+
}
68+
69+
function applyAll(): void {
70+
const cards = document.querySelectorAll<HTMLElement>(CARD_SELECTOR);
71+
for (const card of cards) applyToCard(card);
72+
}
73+
74+
// ─── SPA 动态加载监听 ────────────────────────
75+
76+
let scheduled = false;
77+
function scheduleApply(): void {
78+
if (scheduled) return;
79+
scheduled = true;
80+
requestAnimationFrame(() => {
81+
scheduled = false;
82+
applyAll();
83+
});
84+
}
85+
86+
/** 我们自己注入的面板根节点,需排除以免 number-flow 动画触发无限重应用 */
87+
let panelRoot: HTMLElement | null = null;
88+
89+
function isOwnMutation(target: Node | null): boolean {
90+
if (!panelRoot || !(target instanceof Node)) return false;
91+
return panelRoot.contains(target);
92+
}
93+
94+
function observeMutations(): void {
95+
const observer = new MutationObserver((mutations) => {
96+
for (const m of mutations) {
97+
if (m.addedNodes.length === 0) continue;
98+
if (isOwnMutation(m.target)) continue;
99+
scheduleApply();
100+
return;
101+
}
102+
});
103+
observer.observe(document.body, { childList: true, subtree: true });
104+
}
105+
106+
// ─── 快捷翻页 ────────────────────────────────
107+
//
108+
// 站点使用 Element Plus 分页组件:button.btn-prev / button.btn-next。
109+
// 按 [ 上一页,] 下一页。点击对应按钮触发 Vue 翻页。
110+
const PREV_SELECTOR = 'button.btn-prev';
111+
const NEXT_SELECTOR = 'button.btn-next';
112+
113+
/** 当前焦点是否落在可输入元素上(避免拦截正常输入) */
114+
function isTypingTarget(el: EventTarget | null): boolean {
115+
if (!(el instanceof HTMLElement)) return false;
116+
const tag = el.tagName;
117+
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || el.isContentEditable;
118+
}
119+
120+
function clickPager(selector: string): void {
121+
const btn = document.querySelector<HTMLButtonElement>(selector);
122+
if (!btn || btn.disabled) return;
123+
btn.click();
124+
// 翻页后滚动到顶部,便于从头浏览
125+
window.scrollTo({ top: 0 });
126+
}
127+
128+
function onKeydown(e: KeyboardEvent): void {
129+
if (e.key !== '[' && e.key !== ']') return;
130+
if (isTypingTarget(e.target)) return;
131+
if (e.ctrlKey || e.metaKey || e.altKey) return;
132+
e.preventDefault();
133+
clickPager(e.key === '[' ? PREV_SELECTOR : NEXT_SELECTOR);
134+
}
135+
136+
function setupHotkeys(): void {
137+
window.addEventListener('keydown', onKeydown, true);
138+
}
139+
140+
// ─── 浮动面板 ────────────────────────────────
141+
142+
const PANEL_CSS = `
143+
.frf-row {
144+
display: flex;
145+
align-items: center;
146+
justify-content: space-between;
147+
gap: 12px;
148+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
149+
font-size: 13px;
150+
color: #333;
151+
}
152+
.frf-label { color: #444; user-select: none; }
153+
.frf-switch {
154+
position: relative;
155+
width: 44px;
156+
height: 24px;
157+
flex: none;
158+
cursor: pointer;
159+
}
160+
.frf-switch input { display: none; }
161+
.frf-switch__track {
162+
display: block;
163+
width: 100%;
164+
height: 100%;
165+
border-radius: 12px;
166+
background: #ccc;
167+
transition: background 0.2s;
168+
}
169+
.frf-switch__thumb {
170+
position: absolute;
171+
top: 2px;
172+
left: 2px;
173+
width: 20px;
174+
height: 20px;
175+
border-radius: 50%;
176+
background: #fff;
177+
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
178+
transition: transform 0.2s;
179+
}
180+
.frf-switch input:checked + .frf-switch__track { background: #4d6bfe; }
181+
.frf-switch input:checked + .frf-switch__track + .frf-switch__thumb {
182+
transform: translateX(20px);
183+
}
184+
.frf-slider-host { margin-top: 14px; }
185+
.frf-slider-host.frf-disabled { opacity: 0.5; pointer-events: none; }
186+
.frf-hint {
187+
margin-top: 14px;
188+
font-size: 12px;
189+
color: #999;
190+
line-height: 1.6;
191+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
192+
}
193+
`;
194+
195+
function buildPanelHTML(): string {
196+
return `
197+
<div style="display:flex;flex-direction:column;">
198+
<div class="frf-row">
199+
<span class="frf-label">启用评分过滤</span>
200+
<label class="frf-switch">
201+
<input id="frf-toggle" type="checkbox" ${config.enabled ? 'checked' : ''}>
202+
<span class="frf-switch__track"></span>
203+
<span class="frf-switch__thumb"></span>
204+
</label>
205+
</div>
206+
<div id="frf-slider-host" class="frf-slider-host ${config.enabled ? '' : 'frf-disabled'}"></div>
207+
<div class="frf-hint">低于阈值的卡片将被隐藏;「暂无评分」按 0 分处理,开启时一并隐藏。<br>快捷键:[ 上一页,] 下一页。</div>
208+
</div>`;
209+
}
210+
211+
function setupPanel(): void {
212+
const style = document.createElement('style');
213+
style.textContent = PANEL_CSS;
214+
document.head.appendChild(style);
215+
216+
createFloatingPanel({
217+
title: 'Fufugal 评分过滤',
218+
content: buildPanelHTML(),
219+
width: 320,
220+
height: 220,
221+
position: { x: window.innerWidth - 340, y: 80 },
222+
}).show();
223+
224+
// 等待面板内容挂载后再绑定控件
225+
setTimeout(() => {
226+
bindControls();
227+
}, 100);
228+
}
229+
230+
function bindControls(): void {
231+
const toggle = document.querySelector<HTMLInputElement>('#frf-toggle');
232+
const sliderHost = document.querySelector<HTMLElement>('#frf-slider-host');
233+
if (!toggle || !sliderHost) return;
234+
235+
// 记录面板根节点,供 MutationObserver 排除自身动画
236+
panelRoot = (sliderHost.closest('.xlxz-root') as HTMLElement) ?? sliderHost;
237+
238+
const slider = createAnimatedSlider({
239+
min: 0,
240+
max: 10,
241+
step: 0.1,
242+
value: config.threshold,
243+
label: '阈值',
244+
onChange: (value) => {
245+
config = { ...config, threshold: clampThreshold(value) };
246+
saveConfig(config);
247+
applyAll();
248+
},
249+
});
250+
sliderHost.appendChild(slider.getElement());
251+
252+
toggle.addEventListener('change', () => {
253+
config = { ...config, enabled: toggle.checked };
254+
saveConfig(config);
255+
sliderHost.classList.toggle('frf-disabled', !config.enabled);
256+
applyAll();
257+
});
258+
}
259+
260+
// ─── 入口 ─────────────────────────────────────
261+
262+
function init(): void {
263+
setupPanel();
264+
applyAll();
265+
observeMutations();
266+
setupHotkeys();
267+
}
268+
269+
if (document.readyState === 'loading') {
270+
document.addEventListener('DOMContentLoaded', init);
271+
} else {
272+
init();
273+
}

src/fufugal-rating-filter/meta.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { defineConfig } from '../shared/define';
2+
3+
export default defineConfig({
4+
meta: {
5+
name: 'Fufugal 评分过滤',
6+
namespace: 'https://github.com/XiaoLinXiaoZhu/JavaScriptTools',
7+
version: '0.3.0',
8+
description: '为 fufugal.com(我的Galgame资源发布站)按评分阈值过滤卡片,可在浮动面板中开关与调节阈值',
9+
author: 'XLXZ',
10+
match: 'https://fufugal.com/*',
11+
grant: 'none',
12+
license: 'MIT',
13+
'run-at': 'document-idle',
14+
},
15+
category: 'tools',
16+
});

0 commit comments

Comments
 (0)