// ==UserScript==
// @name 游民星空图片助手
// @namespace https://github.com/addpd/gamersky-image-helper
// @version 0.1
// @description 为游民星空网站提供图片下载功能的油猴插件
// @author addpd
// @copyright 2024, addpd (https://github.com/addpd)
// @license MIT
// @match https://www.gamersky.com/news/*/*.shtml
// @match https://www.gamersky.com/ent/*/*.shtml
// @match https://www.gamersky.com/wenku/*/*.shtml
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @run-at document-start
// @require https://cdnjs.cloudflare.com/ajax/libs/draggabilly/2.3.0/draggabilly.pkgd.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// ==/UserScript==
(function () {
'use strict';
class GamerskyImageHelper {
constructor() {
this.imageModeActive = GM_getValue('imageModeActive', true);
this.adBlockActive = GM_getValue('adBlockActive', true);
this.removeWatermark = true;
this.isDragging = false;
this.initStyles();
// 检测body存在后再执行appendChild
document.addEventListener('DOMContentLoaded', () => {
this.createUI();
this.initEventListeners();
this.initSettings();
this.sayHi();
});
}
initStyles() {
this.imageModeCSS = `
/* 文章 */
/* 隐藏文章 Mid2L_con 下的所有文本节点 */
.Mid2L_con,
.Mid2L_con > p,
.Mid2L_con > p > span {
font-size: 0 !important;
}
/* 评论区 */
/* 隐藏 cmt_msg 下的所有文本节点 */
.cmt_msg {
font-size: 0 !important;
}
/* 隐藏特定的文本内容 */
.cmt_msg .cmt_con,
.cmt_msg .cmt_reply {
display: none !important;
}
/* 隐藏没有图片的评论 */
.cmt_cont:not(:has(.qzcmt-piclist)) {
display: none !important;
}
`;
GM_addStyle(`
.ant-btn {
line-height: 1.5715;
position: relative;
display: inline-block;
font-weight: 400;
white-space: nowrap;
text-align: center;
background-image: none;
border: 1px solid transparent;
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.015);
cursor: pointer;
transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1);
user-select: none;
touch-action: manipulation;
height: 32px;
padding: 4px 15px;
font-size: 14px;
border-radius: 2px;
color: rgba(0, 0, 0, 0.85);
background: #fff;
border-color: #d9d9d9;
}
.ant-btn-primary {
color: #fff;
background: #1890ff;
border-color: #1890ff;
text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.12);
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.045);
}
.ant-btn-sm {
height: 26px;
padding: 0 8px;
font-size: 14px;
border-radius: 2px;
margin:5px 0;
}
.ant-btn-toggle {
color: rgba(0, 0, 0, 0.85);
background: #fff;
border-color: #d9d9d9;
}
.ant-btn-toggle.active {
color: #fff;
background: #52c41a;
border-color: #52c41a;
}
.ant-btn:active {
color: #096dd9;
background: #fff;
border-color: #096dd9;
}
.ant-btn-primary:active {
color: #fff;
background: #096dd9;
border-color: #096dd9;
}
.ant-btn-toggle:active {
color: #fff;
background: #096dd9;
border-color: #096dd9;
}
.ant-radio-group {
box-sizing: border-box;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5715;
list-style: none;
font-feature-settings: 'tnum';
display: inline-block;
line-height: unset;
}
.ant-radio-button-wrapper {
position: relative;
display: inline-block;
height: 26px;
margin: 0;
padding: 0 8px;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
line-height: 24px;
background: #fff;
border: 1px solid #d9d9d9;
border-top-width: 1.02px;
border-left-width: 0;
cursor: pointer;
transition: color 0.3s, background 0.3s, border-color 0.3s, box-shadow 0.3s;
}
.ant-radio-button-wrapper:first-of-type {
border-left: 1px solid #d9d9d9;
border-radius: 2px 0 0 2px;
}
.ant-radio-button-wrapper:last-of-type {
border-radius: 0 2px 2px 0;
margin-left: -4px;
}
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled) {
z-index: 1;
color: #1890ff;
background: #fff;
border-color: #1890ff;
}
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled)::before {
background-color: #1890ff;
}
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):first-child {
border-color: #1890ff;
}
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):hover {
color: #40a9ff;
border-color: #40a9ff;
}
.ant-radio-button-wrapper-checked:not(.ant-radio-button-wrapper-disabled):active {
color: #096dd9;
border-color: #096dd9;
}
.ant-radio-button-input {
display: none;
}
.ant-radio-button-wrapper:has(.ant-radio-button-input:checked) {
background: #1890ff;
color: #fff;
}
#floatingMenu {
h6 { margin: 10px 0; }
div[class$="_edit"] {
display: flex;
gap: 5px;
justify-content: space-between;
border: 1px solid #d6d6d6;
padding: 10px 10px 0 10px;
border-radius: 5px;
flex-direction: column;
}
span[id$="_img_num"] { font-size: .75em; }
& #other_edit {
display: flex;
flex-direction: row;
justify-content: start;
gap: 10px;
}
.edit_title{
font-size:14px;
}
}
`);
}
createUI() {
const menuHTML = `
<span style="pointer-events: none;">+</span>
<div id="floatingMenu" style="
position: absolute;
top: 0;
right: 40px;
background-color: white;
border: 1px solid rgb(222, 226, 230);
border-radius: 0.25rem;
box-shadow: rgba(0, 0, 0, 0.14) 0px 2px 4px 0px;
display: none;
padding: 0 10px 10px 10px;
color: #151515;
text-align: left;
cursor: auto;
">
<div>
<h6>文章区操作</h6>
<div class="article_edit">
<div class="ant-radio-group">
<span class="edit_title">图片下载方式:</span>
<label class="ant-radio-button-wrapper">
<span class="ant-radio-button">
<input type="radio" class="ant-radio-button-input" name="article_download_type" value="zip" checked=checked>
<span class="ant-radio-button-inner"></span>
</span>
<span>打包</span>
</label>
<label class="ant-radio-button-wrapper">
<span class="ant-radio-button">
<input type="radio" class="ant-radio-button-input" name="article_download_type" value="single">
<span class="ant-radio-button-inner"></span>
</span>
<span>逐个</span>
</label>
</div>
<button id="downloadArticleBtn" class="ant-btn ant-btn-primary ant-btn-sm">开始下载文章图</button>
</div>
</div>
<div>
<h6>评论区操作</h6>
<div class="comment_edit">
<div class="ant-radio-group">
<span class="edit_title">图片下载方式:</span>
<label class="ant-radio-button-wrapper">
<span class="ant-radio-button">
<input type="radio" class="ant-radio-button-input" name="comment_download_type" value="zip" checked=checked>
<span class="ant-radio-button-inner"></span>
</span>
<span>打包</span>
</label>
<label class="ant-radio-button-wrapper">
<span class="ant-radio-button">
<input type="radio" class="ant-radio-button-input" name="comment_download_type" value="single">
<span class="ant-radio-button-inner"></span>
</span>
<span>逐个</span>
</label>
</div>
<button id="downloadCommentsBtn" class="ant-btn ant-btn-primary ant-btn-sm">开始下载评论图</button>
</div>
</div>
<div>
<h6>其他操作</h6>
<div id="other_edit">
<div class="other_edit">
<span class="edit_title">页面导航:</span>
<div>
<button id="topBtn" class="ant-btn ant-btn-primary ant-btn-sm">去顶部</button>
<button id="commentsBtn" class="ant-btn ant-btn-primary ant-btn-sm">去评论</button>
</div>
</div>
<div class="other_edit">
<span class="edit_title">一些选项:</span>
<div>
<button id="imageModeBtn" class="ant-btn ant-btn-toggle ant-btn-sm">只看图</button>
<button id="adBlockBtn" class="ant-btn ant-btn-toggle ant-btn-sm">去广告</button>
</div>
</div>
</div>
</div>
</div>
`;
this.floatDiv = document.createElement('div');
this.floatDiv.style.cssText = `
position: fixed;
right: 30px;
top: 25%;
width: 40px;
height: 40px;
z-index: 9999;
overflow: unset;
`;
document.body.appendChild(this.floatDiv);
this.floatButton = document.createElement('button');
this.floatButton.className = 'ant-btn ant-btn-primary ant-btn-sm';
this.floatButton.style.cssText = `
width: 100%;
height: 100%;
font-size: 20px;
line-height: 1;
padding: 0;
border-radius: 50%;
`;
this.floatDiv.appendChild(this.floatButton);
this.floatButton.innerHTML = menuHTML;
this.floatingMenu = document.getElementById('floatingMenu');
}
initEventListeners() {
document.getElementById('topBtn')?.addEventListener('click', this.scrollToTop.bind(this));
document.getElementById('commentsBtn')?.addEventListener('click', this.scrollToComments.bind(this));
document.getElementById('downloadArticleBtn')?.addEventListener('click', this.batchDownloadCurrentPagePictures.bind(this));
document.getElementById('downloadCommentsBtn')?.addEventListener('click', this.batchDownloadCommentPicturesAsZip.bind(this));
document.getElementById('imageModeBtn')?.addEventListener('click', this.toggleImageMode.bind(this));
document.getElementById('adBlockBtn')?.addEventListener('click', this.toggleAdBlock.bind(this));
this.floatButton.onclick = this.toggleMenu.bind(this);
document.addEventListener('click', this.hideMenuOnClickOutside.bind(this));
this.initDraggable();
}
initDraggable() {
const draggie = new Draggabilly(this.floatDiv, {
containment: 'body'
});
draggie.on('dragStart', () => {
this.isDragging = true;
this.floatDiv.style.cursor = 'grabbing';
});
draggie.on('dragEnd', () => {
setTimeout(() => {
this.isDragging = false;
this.floatDiv.style.cursor = 'move';
}, 0);
});
window.addEventListener('resize', () => {
const rect = this.floatDiv.getBoundingClientRect();
if (rect.right > window.innerWidth) {
draggie.setPosition(window.innerWidth - rect.width, rect.top);
}
if (rect.bottom > window.innerHeight) {
draggie.setPosition(rect.left, window.innerHeight - rect.height);
}
});
}
toggleMenu(event) {
if (event.target === this.floatButton && !this.isDragging) {
event.stopPropagation();
if (this.floatingMenu.style.opacity === '0' || this.floatingMenu.style.opacity === '') {
this.floatingMenu.style.display = 'block';
setTimeout(() => {
this.floatingMenu.style.opacity = '1';
this.floatingMenu.style.transition = 'opacity 0.1s ease-in-out';
}, 10);
} else {
this.floatingMenu.style.opacity = '0';
this.floatingMenu.style.transition = 'opacity 0.1s ease-in-out';
setTimeout(() => {
this.floatingMenu.style.display = 'none';
}, 300);
}
}
}
hideMenuOnClickOutside(event) {
if (event.target !== this.floatButton && !this.floatButton.contains(event.target)) {
this.floatingMenu.style.opacity = '0';
this.floatingMenu.style.transition = 'opacity 0.1s ease-in-out';
setTimeout(() => {
this.floatingMenu.style.display = 'none';
}, 300);
}
}
scrollToTop() {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
scrollToComments() {
document.querySelector('.Comment')?.scrollIntoView({ behavior: 'smooth' });
}
async downloadImages(urls) {
for (let i = 0; i < urls.length; i++) {
await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: urls[i],
responseType: "blob",
onload: function (response) {
const blob = response.response;
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
const fileName = urls[i].split('/').pop();
link.download = fileName;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
resolve();
},
onerror: function (error) {
console.error('下载失败:', error);
reject(error);
}
});
});
}
}
async batchDownloadCurrentPagePictures() {
const images = document.querySelectorAll(".picact");
const imageCount = images.length;
const downloadType = this.getDownloadType('article');
if (!this.downlaodConfirm({
from: '文章区',
imgLen: imageCount,
downloadType
})) {
return;
}
const urls = Array.from(images).map(img =>
this.removeWatermark ? img.src.replace("_S.", ".") : img.src
);
try {
if (downloadType === 'zip') {
await this.downloadImagesAsZip(urls, '文章区');
} else if (downloadType === 'single') {
await this.downloadImages(urls);
}
} catch (error) {
console.error('批量下载图片失败:', error);
}
}
async downloadImagesAsZip(urls, from) {
const zip = new JSZip();
let successCount = 0;
const maxImagesToDownload = 5;
const isTestEnvironment = false;
for (let i = 0; i < (isTestEnvironment ? Math.min(urls.length, maxImagesToDownload) : urls.length); i++) {
try {
console.log(`尝试下载图片 ${i + 1}: ${urls[i]}`);
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: urls[i],
headers: { "Referer": "https://www.gamersky.com/" },
responseType: "blob",
timeout: 30000,
onload: resolve,
onerror: reject,
ontimeout: reject
});
});
if (response.status !== 200) throw new Error(`HTTP错误! 状态: ${response.status}`);
const blob = response.response;
const fileName = urls[i].split('/').pop();
zip.file(fileName, blob);
console.log(`成功下载图片 ${i + 1}`);
successCount++;
} catch (error) {
console.error(`下载图片 ${urls[i]} 失败:`, error);
if (error.name === 'TimeoutError') {
console.log('下载超时,跳过此图片');
}
}
await new Promise(resolve => setTimeout(resolve, Math.random() * 300 + 500));
}
if (successCount === 0) {
console.error("没有成功下载任何图片");
return;
}
console.log(`总共成功下载 ${successCount} 张图片`);
console.log("开始创建ZIP文件...");
try {
const zipContent = await zip.generateAsync({ type: "blob" });
console.log("ZIP文件创建完成,准备下载...");
const blobUrl = URL.createObjectURL(zipContent);
const link = document.createElement('a');
link.href = blobUrl;
const now = new Date();
const timestamp = `${now.getFullYear()}${(now.getMonth() + 1).toString().padStart(2, '0')}${now.getDate().toString().padStart(2, '0')}_${now.getHours().toString().padStart(2, '0')}${now.getMinutes().toString().padStart(2, '0')}`;
const pageTitle = document.title.replace(/[<>:"/\\|?*\x00-\x1F]/g, '').trim();
let currentPage;
if (from === '文章区') {
currentPage = document.querySelector('.page_css b')?.textContent || '1';
} else if (from === '评论区') {
currentPage = document.querySelector('.pagelist .curr')?.textContent || '1';
}
const zipFileName = `gamersky_【${pageTitle}】_${from}_第${currentPage}页_${timestamp}.zip`;
link.download = zipFileName;
link.click();
URL.revokeObjectURL(blobUrl);
console.log(`ZIP文件下载成功,文件名:${zipFileName}`);
} catch (error) {
console.error("创建或下载ZIP文件时出错:", error);
alert("下载过程中出错,请查看控制台以获取更多信息。");
}
}
isValidImageUrl(url) {
return url.match(/\.(jpeg|jpg|gif|png|webp|svg|bmp|ico|tiff)$/i) != null;
}
getOriginalImageUrl(url) {
const isGif = url.includes('imggif.gamersky.com');
const isImg1 = url.includes('img1.gamersky.com');
if (isGif || isImg1) {
let newUrl = url.replace('tinysquare_', 'origin_');
return isGif ? newUrl.replace(/\.jpg$/i, '.gif') : newUrl;
}
return url;
}
getDownloadType(area = 'article') {
return document.querySelector(`input[name="${area}_download_type"]:checked`)?.value;
}
async batchDownloadCommentPicturesAsZip() {
let piclistUrls = [];
document.querySelectorAll(".qzcmt-piclist img").forEach((i) => {
// 如果是符合的图片则push进去
if (this.isValidImageUrl(i.src)) {
// 直接push评论原图
let url = this.getOriginalImageUrl(i.src);
piclistUrls.push(url);
}
});
if (piclistUrls.length === 0) {
alert("没有找到可下载的图片");
return;
}
console.log("找到的图片URL:", piclistUrls);
const downloadType = this.getDownloadType('comment');
// 确认
if (!this.downlaodConfirm({
from: '评论区',
imgLen: piclistUrls.length,
downloadType
})) {
return;
}
try {
if (downloadType === 'zip') { // 打包下载
await this.downloadImagesAsZip(piclistUrls, '评论区');
} else if (downloadType === 'single') { // 逐个下载
await this.downloadImages(piclistUrls);
}
} catch (error) {
console.error("下载过程中出错:", error);
alert("下载过程中出错,请查看控制台以获取更多信息。");
}
}
downlaodConfirm(data) {
let confirmMessage = `当前 ${data.from} 找到 ${data.imgLen} 张图片,\n是否${data.downloadType === 'zip' ? '打包' : '逐个'}下载?`;
return confirm(confirmMessage);
}
toggleImageMode() {
this.imageModeActive = !this.imageModeActive;
GM_setValue('imageModeActive', this.imageModeActive);
document.getElementById('imageModeBtn')?.classList.toggle('active', this.imageModeActive);
this.applyImageMode();
}
toggleAdBlock() {
this.adBlockActive = !this.adBlockActive;
GM_setValue('adBlockActive', this.adBlockActive);
document.getElementById('adBlockBtn')?.classList.toggle('active', this.adBlockActive);
this.applyAdBlock();
}
applyImageMode() {
const styleId = 'gm-image-mode-style';
let styleElement = document.getElementById(styleId);
if (this.imageModeActive) {
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = styleId;
styleElement.textContent = this.imageModeCSS;
document.head.appendChild(styleElement);
}
} else {
if (styleElement) {
styleElement.remove();
}
}
}
applyAdBlock() {
const styleId = 'gm-ad-block-style';
let styleElement = document.getElementById(styleId);
if (this.adBlockActive) {
if (!styleElement) {
styleElement = document.createElement('style');
styleElement.id = styleId;
styleElement.textContent = '.MidRPicTxt, .yyimg ,.Mid2L_con>p[align=center]:not(.GsImageLabel){ display: none !important; }';
document.head.appendChild(styleElement);
}
} else {
if (styleElement) {
styleElement.remove();
}
}
}
initSettings() {
document.getElementById('imageModeBtn')?.classList.toggle('active', this.imageModeActive);
document.getElementById('adBlockBtn')?.classList.toggle('active', this.adBlockActive);
this.applyImageMode();
this.applyAdBlock();
}
sayHi() {
console.log('%c✅ 游民沙雕图插件已生效', 'padding:6px 12px 6px 10px;color:green;border:1px solid green;font-size:12px;');
}
}
new GamerskyImageHelper();
})();