// ==UserScript==
// @name Comic Looms
// @name:zh-CN 漫画织机
// @name:zh-TW 漫畫織機
// @name:ja コミック織機
// @name:ko 만화 베틀
// @name:es Comic Looms
// @name:ka Comic Looms
// @namespace https://github.com/MapoMagpie/eh-view-enhance
// @version 4.10.21
// @author MapoMagpie
// @description Manga Viewer + Downloader, Focus on experience and low load on the site. Support you in finding the site you are searching for.
// @description:zh-CN 漫画阅读 + 下载器,注重体验和对站点的负载控制。支持你正在搜索的站点。
// @description:zh-TW 漫畫閱讀 + 下載器,注重體驗和對站點的負載控制。支持你正在搜索的站點。
// @description:ja サイトのエクスペリエンスと負荷制御に重点を置いたコミック閲覧 + ダウンローダー。あなたが探しているサイトを見つけるのをサポートします。
// @description:ko 이 유저 스크립트는 특정 사이트들 에서 갤러리 또는 작가의 홈페이지를 빠르고 편리하게 탐색할 수 있도록 하며, 일괄 다운로드 기능을 지원합니다. 브라우징 경험과 낮은 사이트 부하에 중점을 둡니다.
// @description:es Este Userscript permite una navegación rápida y conveniente por galerías o páginas principales de artistas en ciertos sitios, con soporte para descargas por lotes, enfocándose en la experiencia de navegación y en una carga baja para el sitio.
// @description:ka Manga Viewer + Downloader, Focus on experience and low load on the site. Support you in finding the site you are searching for.
// @license MIT
// @icon data:image/x-icon;base64,AAABAAEAQEAAAAEAIAAoQgAAFgAAACgAAABAAAAAgAAAAAEAIAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAADF2+r/xdzo/8Xc6P/F3Oj/xdzo/8Xc6P/F3Oj/xdzo/8Xc6P/F3Of/xdzn/8Xc5//F3Oj/xdzo/8Xc6P/F3Oj/xdzo/8Tb5//E2+f/xdzo/8Xc6P/F3Oj/xdzo/8Xc6P/F3Oj/xdzo/8Xc6P/F3Oj/xdzo/8Xc6P/F3Oj/xdzo/8Xc6P/F3Oj/xdzo/8Xc6P/E2+f/xNvn/8Xb6P/E3Of/xNvn/8Tb5//F3Oj/xdzo/8Xc6P/F3Oj/xdzo/8Xc6P/F3Oj/xNvn/8Tb5//E2+f/xNvn/8Xc6P/F3Oj/xdzo/8Xc6P/F3Oj/xdzo/8Xc6P/F3Oj/xdzo/8Xc6P/F3Oj/xdvp/8Xd5//F3eb/xd3m/8Xd5//F3ef/xd3n/8Xd5//F3ef/xd3n/8Xd5//F3ef/xd3n/8Xd5//F3eb/xd3m/8Xd5v/F3eb/xd3m/8Xe5//F3eb/xd3m/8Xd5v/F3eb/xd3m/8Xd5//F3eb/xd3m/8Xd5//F3eb/xd3m/8Xd5v/F3eb/xd3n/8Xd5//F3ef/xd3n/8Xd5//E3OX/w9vm/8Tc5//D2+X/wtvk/8Xd5//F3eb/xd3m/8Xd5v/F3eb/xd3m/8Xd5v/F3ef/xd3n/8Xe5v/F3uf/xd3m/8Xe5v/F3eb/xd3n/8Xd5v/F3eb/xd3n/8Xd5//F3ef/xdzo/8Xb6f/F3eb/xd7l/8Xe5f/D3uX/xN7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xN7l/8Te5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5P/F3uT/xd3k/8Xd5P/F3eT/xd3k/8Xd5P/E3OP/zeXr/9Dm6//N4uf/0uju/9Hq8f/H4On/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5v/F3ef/xd3n/8Xd6f/F2ur/xd3n/8Xe5f/E3uX/w97l/8Pe5f/F3uX/xN3k/8Xd5P/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uT/xd3k/8Xd5P/F3eT/xd3k/8Xd5P/F3eP/xN3j/8Td4//E3eT/xN3k/8Td5P/E3eT/xN3l/8Pe5f/D3ub/xN/n/8Tf6P/E4Oj/xOHp/8Xh6//F4Or/0ez2/5aquv9YZ4X/YXGQ/1Jbd/+LmKj/yd3l/8be5//F3uT/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/E3uX/xd7m/8Xd5//F3ej/xdrq/8Xd5//F3uX/xd7l/8Te5f/F3uX/xd7l/8ji6v/G4Oj/xN3l/8Tc5P/E3eT/xN3k/8Pd5f/E3uf/xN/o/8Tf6P/E3+r/xOHq/8bi7P/G4+z/x+Tu/8jl7v/J5e//y+Xv/8vl7v/M5e3/zeXs/87k6v/P4uj/z+Dj/9De3//O2dr/zdXV/8zRzv/IysX/xMK8/8rEvv+Ng4P/T2yk/01spv8ZKmj/DCBs/3yFmP/b8fj/w93p/8Td5f/E3eT/xd7l/8Td5P/E3eT/xN3k/8Xe5f/F3uX/xd7l/8Xe5f/E3uX/w97l/8Te5f/F3uX/xd7l/8Xe5f/F3ef/xd3n/8Xb6v/F3ef/xd7l/8Xe5f/F3uX/xt7m/8fd5P+6ztP/xNnd/8zj6f/N5/D/zubu/87l7P/O4+j/z+Hk/8/e3//O2tr/ztXU/8vQzv/Iy8f/xcS+/8K7tP+9s6v/uayj/7Okmv+unZL/p5WK/6CNg/+ZhXz/kn14/4p2dP+CcXT/e21y/3Rpcv9vZXL/aWRy/2Nhdf9hZH3/VFNn/0BSf/9JZqf/IjNy/x45jf9DP1f/tqid/9DT0P/P4+r/yePv/8Pd5v/G4Oj/yuTs/8rj6//D3eT/xN3k/8Xe5f/E3uX/w97l/8Te5f/F3uX/xd7l/8Xe5f/F3uX/xd3n/8Xd6P/F2ur/xd3n/8Xe5f/F3uX/w9zj/8/n7/+Il6v/QVF8/y07af9laHv/r6KZ/5+Ph/+Zh3//kH95/4l4dP+EdHT/fW9z/3drcv9yaXP/bGZ1/2Zjd/9fYHj/Wl99/1phg/9VYYf/UWGN/1BklP9PZpr/T2mi/01sqf9NcLD/UXS5/1F4vv9Te8P/VX3H/1eAzP9Ygc3/XojX/0lpr/8+Tnv/VXOv/yU1dv8eLWn/GiZT/y8zWP9uW2H/n4yE/8XAuf/V4uT/xNnf/7HE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h/+4ytP/yuLs/8bc5//E2ur/xNvl/8vl7P+sv8v/Exkp/xsqQf84RGr/pqyu/0BNbv9bitj/PViW/zdKf/8dMHX/b3GI/9DOy/++vL3/v769/8vJxf/KycT/yMbC/8fEwP/Ewb3/wb65/7+7tv+/u7X/vbmz/7q2sf+2tK//tbOu/7Wyrv+zsav/sK6p/7Ctqf+tqqf/qqmk/6elov+urKf/iYyQ/x4qVP8jLFz/YYzT/1uFyv8uPGj/O0+J/0RZj/9EXZb/MEiO/0tmpv9AT3z/LkWN/zZUqf9BS3L/b4ey/2uEsv9pg7L/aYS0/2R9rf9bdKb/Ljts/yYxYf88Unj/uczV/8nh6//F3ef/xdrq/8La5P/V7PP/ZG+C/xAePP8pO2H/NENx/2Bqfv9cZ4H/VITT/0Jdm/9feqX/MU+b/8PDyv////////////////////////////////////////////////////////////////////////////////////7////9/////P////v///76///9+f/7+vb////+/56ptP83Uof/Lzx6/2CM0v9ahMj/O057/0FXk/85S4L/Lj5u/yU6fP9NaKr/VmaI/zBIkf8cMYD/e4aU/9rx8v/J3uP/zOHl/8vf4//R5Ob/cIKb/y8+a/85T43/SFWA/8nd4v/G3ej/xt3n/8Xa6f/G3Of/y+Dn/0BJZ/8hNF3/KThp/1Rffv80R33/NUBh/12M2v84UpP/aYCi/y9Gi/9hZ4X/p6Wm/6Ojqv+trbD/t7e5/7u6vP++vb//wcDC/8XExf/Jycn/zc3N/9DQ0P/S0tL/1tXW/9rZ2f/d3Nz/4N/e/+Tj4f/n5eT/6ujn/+3s6v/w7+7/8PDv//36+P+grLj/U22a/yw6df9gjNP/WYPH/z1Qe/89U4z/HilS/z5Siv8vS5v/UG2s/1Vrjf8UGDf/VVtw/7nO1v/H4en/w9zk/8Td5f/C2+P/zOft/5iouf8sOmD/M0Nr/4KRov/R6fH/xNvk/8bd6P/E2un/yd/q/77S2f84RWf/KkBq/yo4av9KVHf/Jzdu/yk5af9ijtj/MUmO/0VYfv8+T3f/DxhT/xQgV/8ULH7/QU11/9LNyP/X0tH/0c3L/9DMyv/Mysb/ycbD/8XDv//Dwb7/wL68/768uv+9urn/vbm3/7u4tv+4t7T/trWy/7Sysv+ysK//sLCu/66urv+xsK//lpqg/01olP8fK1r/YIzR/1mDyv8+UHv/RFmN/yU2Z/8wQXb/MEF2/0Vckf8+UoD/Gh00/8bY3f/N5+7/w9zj/8Xe5f/F3uX/xd7l/8Td5P/L4+r/nq+6/5Skrf/J4uj/xd7m/8Xd5//H3ef/xNrp/8ng6v+80dj/PElp/ylAav9IVYT/p621/xokUP8nNWP/ZI7Y/zZPlP8uPnT/Ok58/xkfSP8YH0L/Ex5F/zg7Tf+WjYH/oJOI/6SVif+rmIz/sJyN/7ehkP+6ppP/vqqX/8Owmv/Isp3/zbWh/9C6pf/Svqn/1sKt/9rIsf/czbb/38+8/+PTwP/l1sT/5tnJ/+3ezv+enaL/RE5t/1yIy/9bhsv/RliF/05ilP8rPHb/LD58/x8pVP8bI0v/KTRs/yQrUP+zxMr/yePq/8Td5P/F3uX/xd7l/8Xe5f/F3uX/xN3k/87o7v/Q6vH/xd7l/8Xe5f/F3ef/xt3o/8Xa6f/H3ef/x93m/1Bbcv8vRm3/MUFw/ycuUv8YIVP/NkNq/2KL2f82TpH/MUWC/zBBgP8pNWv/JC9j/yIrWv8nMV3/LDhi/y04Yv8uOGH/LTdd/y41XP8uNVr/LzdY/zE3Vv8yNlP/NDdS/zY5Uf83O0//OzxP/zw8Tv8+PU3/QT9N/0NCTf9IRE7/S0ZP/0xIT/9RTFT/U0pO/zY4SP9ei9H/XIjM/0JVhv9QZZn/Lj5v/yQvXf8sO3b/MUB1/yo6ef9NVXX/zuLo/8Te5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/D3OP/w9zj/8Xe5f/F3uX/xd3n/8Xd6P/F2+r/xNrk/9Lp8f9zgJH/KDlT/y5Dcv8bJVD/gIyd/3SFov9Vf8//OFGW/y5Bfv87UJT/Q1ik/0Rdp/9HZK3/Smat/0hlqf9JZKj/SGGm/0VepP9EXaP/Q1yi/0Baof89WJz/O1SZ/ztTlf88UJH/Ok6Q/zlMjv82Sor/NEeG/zRGhv8yRYX/LkJ//yw/ev8rPnn/JjZt/yk9dP81S4H/XojM/16L0f83TYP/SFuO/ztPh/8rNmL/KjNV/zM+bf8lM2//aHSR/9Xt8//C2+L/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xd5//F3ej/xtvq/8Tc5f/J4+n/uMvX/yw2Sf8cLFL/V2KB/+7++/93iqr/U3/M/zhTlv8vQn//Ok+Q/0Vcov9JZKr/Wnu//2GDx/9kiMz/ZYnO/2WL0P9mjNL/ZozU/2SO1P9lj9X/ZI7V/2WN0/9ljNL/Y4rR/2OJz/9ih83/YIbK/1+Fyv9eg8v/XYPN/1yCzP9agcr/WXzH/0BfrP84U5z/RV+f/12Gy/9gjdL/OlKI/ys3aP81RHL/IitY/y05Zf8wO2X/GCJe/2t3lP/R6u7/wtvj/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/w97l/8Xe5f/F3uX/xd7l/8Xe5f/F3ef/xd3n/8Xb6v/G3ef/w9zj/87n7v+ktcH/P0ha/yErVP9SVWf/T1x4/12I1P85UpX/LT1v/yY2aP8gKlb/LDhn/0tXe/9GU3j/P050/0VUev9HWYH/RVmE/0Vahv9FW4n/RVuN/0ZdkP9JX5H/S2GU/0tilv9MZZv/S2ac/0xnoP9OaKT/Tmmk/09qpv9Sban/U2+r/05sqv83Upj/NkyM/z1Of/9fiM7/YYzU/zpPgv8hLWT/LTt0/y46cP8zPGH/Lzhb/xYgXv94hqD/0uvw/8Lb4v/F3uX/xd7l/8Xe5f/D3uX/w97l/8Pe5f/F3uX/xd7l/8Xe5f/F3uX/xd3n/8Xd6P/E2ur/xd3n/8Xe5f/D3OP/zeft/8/l7f+Xp7T/eISS/2Bxj/9Zgcr/NUuO/zI9ZP86Rmj/SVVw/x4tX/99hJn/eX6S/wUNQP+HlKL/s8XO/6O0v/+gsLz/nKu4/5eltP+SoLD/jZus/4iVqf+BjqP/fImf/3eEm/90gJb/cHqR/2x1jP9ncIj/YmyF/1xng/9WYoH/S1Nw/0ZYh/9DV4r/YorM/12Kz/9FW4//MTpd/y09ef8zQnf/Pkx9/y4zWP8XIWD/j52x/9Dq7//C2+P/xd7l/8Xe5f/F3uX/w97l/8Pe5f/F3uX/xd7l/8Xe5f/F3uX/xd7m/8Xd5//F3uf/xdrq/8Xd5//F3uX/xd7l/8Pc4//E3eT/zObt/9/4+/98kKr/T3a7/y1Agv8bKFr/h5Oj/258mf98iaD/R1Fz/yovTP8fKVz/tsjQ/9Tt9P/M5uz/zuju/87p7//P6vD/0Orw/9Hr8P/R6/H/0urw/9Pr8P/S6/D/0urv/9Hp7v/Q6O3/z+fs/8/l6//O5er/zeTq/8/g5P9keaf/FCVc/0tspv9kkNT/Ok6D/yMsVf8rN2X/LDtq/zVGff8hJkr/ICtm/6e3xf/M5u3/w9zj/8Xe5f/F3uX/w97l/8Pe5f/E3uX/xd7l/8Xe5f/E3uX/xN7l/8Xe5v/F3ef/xd3o/8Xa6v/F3ef/xd7l/8Xe5f/F3uX/xd7l/8HZ4f/N5uz/jJ6u/1R4t/80SpH/Hyhb/8XQ0P9ebYv/wtDZ/4OOnP8EDD//P0lw/8fd4f/D3OT/w9zj/8Pc4//D3OP/w9zj/8Pb4//D2+P/wtvj/8Lb4//C2+P/wtvj/8Pc4//D3OP/w9zj/8Pc5P/E3eT/xd7l/8Td5P/K4ur/g5Sq/256kv9Zcpn/YIjK/ztUjv8uOGX/V112/0lVff9MWXr/ERlG/zZDd/+/09v/yOHo/8Td5P/F3uX/xd7l/8Pe5f/D3uX/w97l/8Pe5f/D3uX/w97l/8Xe5f/F3uX/xd3n/8Xc6f/F2ur/xd3m/8Xe5f/F3uX/xd7l/8Xe5f/E3eT/yeTr/7PGzv9cean/RF+j/xolZ/90fpT/aHSJ/3+Hlf86Q2v/ER1a/3N/kv/T7PH/wtvi/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8vj6v/a8PX/d4um/1l+u/9IY6P/Rk1x/5CWpP9aaI3/cneE/xEfZv9bZ4v/0Obt/8Pc5P/F3uX/xd7l/8Pe5f/E3uX/w97l/8Pe5f/D3uX/w97l/8Pe5f/E3uX/xd7l/8Xd5//F3ej/xdvq/8Xd5v/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Pc4//O5uz/fpCn/0prrP8rOXn/Hi9w/yYyW/8cJVD/FSJf/yQsVP+7zdT/yePq/8Td5P/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/B2uL/y+Tr/6Cwvv9ScaH/XHy3/z5Nff9naXj/JCxG/y86bf8aKGj/kaCv/9Hp8v/D2+P/xd7l/8Xe5f/D3uX/xd7l/8Te5f/D3uX/w97l/8Pe5f/D3uX/w97l/8Xe5f/F3ef/xd3n/8Xa6v/F3ef/xd7l/8Pe5f/D3uX/w97l/8Xe5f/E3eT/x+Ho/7/T2/9ieJ7/PE+F/xkiWv8WJmf/FyVi/yMrUf+Wo67/zufu/8DZ4f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Te5f/D3uX/w97l/8Xe5f/F3uX/xd7l/8Xe5f/E3uX/xN7l/8Te5f/E3uX/xd7l/8Xe5f/F3uX/xd7l/8Td5P/M4+r/eImf/1Jtov86T4n/Ex1O/xQbS/8QG1j/R09u/8vg6P/F3eX/xd7l/8Xe5f/E3uX/w97l/8Xe5f/F3uX/xd7l/8Pe5f/D3uX/w97l/8Pe5f/F3uX/xd3n/8Xd5//F2ur/xd3n/8Xe5f/D3uX/w97l/8Pe5f/E3uX/xd7l/8Lb4v/S7PL/kqKt/z5Vhf9DTXT/l6Gq/7G/x//G2OD/2fD3/8/n7f/Q6e7/xt/m/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xN7l/8Te5f/E3uX/xd7l/8Xe5f/F3uX/xN7l/8Xe5f/E3uX/xN7l/8Xe5f/F3uX/xd7l/8Xe5f/F3eT/xuDn/8bd5P+ClKn/XGuM/zxHbv8rOWj/Ult1/7vO1//J4+r/xN3k/8Xe5f/F3uX/xd7l/8Te5f/E3uX/xd7l/8Te5f/D3uX/w97l/8Pe5f/D3uX/xd7l/8Xd5//F3ej/xdvp/8Xd5//F3uX/w97l/8Pe5f/D3uX/w97l/8Te5f/E3eT/yuTr/6/Dzf84TX//bXyY/5+quf9rdYv/prfE/6O2xv+Yqbz/nK2+/8Tc5f/G3+b/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xN7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Td5P/G3+b/zebs/8rg5f/E2N3/v9PX/9Dn7P/J4un/w93k/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Te5f/D3uX/w97l/8Pe5f/D3uX/xd7l/8Xe5f/F3ef/xd3o/8Xb6f/F3ef/xd7l/8Xe5f/E3uX/w97l/8Pe5f/D3uX/xN3k/8rk6/+vw8v/SWWb/z9QeP8hNXL/HjR5/zlBZv9JWYL/R1iB/05cff+4zNb/yeLq/8Td5P/E3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xN7l/8Pe5f/E3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Pc5P/E3uX/xuDn/8jh6f/D3OT/xN3k/8Xe5f/F3uX/xd7l/8Xe5v/F3uX/xd7l/8Xe5f/E3uX/xN7l/8Te5f/D3uX/xN7l/8Xe5f/F3uX/xd3n/8Xd6P/F2+j/xd3n/8Xe5f/F3uX/xd7l/8Xe5f/E3uX/xd7l/8Xe5f/G4Of/w9nh/26Dnf+Qn7L/lqa5/4iZrf/D1t//wNbc/8Ta3//H3eP/yODn/8Xe5f/G3+b/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/E3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xd5f/E3eT/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/G3+b/xd7l/8Xe5f/F3uX/xN7l/8Te5f/F3uX/xN7l/8Xe5f/F3uX/xd7l/8Xd5//F3ej/xdvp/8Xd5//F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7k/8fg5//K4eb/zuXs/8/o7f/P5+z/yeHo/8jh6P/I4ej/x+Dn/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xt/l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3uX/xd7l/8Xe5f/F3ef/xd3o/8Xc6P/F3ef/xd7m/8Xe5v/F3uf/xd7m/8Xe5v/F3ub/xd7m/8Xe5v/F3ub/xN3m/8Td5f/D3OX/w9zk/8Td5f/F3ef/xd7n/8be5v/F3ub/xd7m/8Xe5//F3ub/xd7m/8Xe5//F3ub/xd7m/8Xe5//F3ub/xd7n/8Xe5v/F3ub/xd7n/8Xe5v/F3ub/xd7n/8Xe5//F3ub/xd7n/8Xe5//F3uf/xd7m/8Xe5//F3uf/xd7m/8Xe5//F3uf/xd7m/8Xe5v/F3uf/xd7n/8be5//F3uf/xd7m/8Xe5v/F3uf/xd7n/8Xe5//F3uf/xd7n/8Xe5//F3ub/xd3m/8bd6v/F3Of/xt3n/8bd5v/G3ub/x97n/8fe5v/H3ub/x97o/8fe5//H3uj/xt/n/8bf5v/G3uf/xt7n/8fe5//H3+b/x97m/8fe5//G3uf/xt7n/8be6P/G3uf/xt7n/8be5v/G3uf/x97m/8fe5v/H3+b/xt7m/8fe5//H3+f/x9/m/8bf5v/G3ub/x9/m/8fe5v/H3uf/xt7n/8be5v/G3uj/xt/n/8bf5v/H3+f/x9/n/8fe5//G3+f/xt/m/8be5v/H3ub/xt7m/8be5//G3uf/xt7n/8bf5//G3+f/x97o/8bf5//G3uf/xt7n/8be5//G3uf/xt7n/8bd6P/G3un/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
// @supportURL https://github.com/MapoMagpie/eh-view-enhance/issues
// @match https://*.pixiv.net/*
// @match https://steamcommunity.com/*
// @match https://twitter.com/*
// @match https://x.com/*
// @match https://*.instagram.com/*
// @match https://*.manhuagui.com/*
// @match https://*.mangacopy.com/*
// @match https://*.copymanga.tv/*
// @match https://*.artstation.com/*
// @match *://*/*
// @require https://cdn.jsdelivr.net/npm/@zip.js/zip.js@2.7.57/dist/zip-full.min.js
// @require https://cdn.jsdelivr.net/npm/file-saver@2.0.5/dist/FileSaver.min.js
// @require https://cdn.jsdelivr.net/npm/pica@9.0.1/dist/pica.min.js
// @connect *
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_xmlhttpRequest
// @downloadURL https://update.greasyfork.icu/scripts/397848/Comic%20Looms.user.js
// @updateURL https://update.greasyfork.icu/scripts/397848/Comic%20Looms.meta.js
// ==/UserScript==
(function (fileSaver, pica, zip_js) {
'use strict';
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
function _interopNamespaceDefault(e) {
const n = Object.create(null, { [Symbol.toStringTag]: { value: 'Module' } });
if (e) {
for (const k in e) {
if (k !== 'default') {
const d = Object.getOwnPropertyDescriptor(e, k);
Object.defineProperty(n, k, d.get ? d : {
enumerable: true,
get: () => e[k]
});
}
}
}
n.default = e;
return Object.freeze(n);
}
const zip_js__namespace = /*#__PURE__*/_interopNamespaceDefault(zip_js);
var _GM_getValue = /* @__PURE__ */ (() => typeof GM_getValue != "undefined" ? GM_getValue : void 0)();
var _GM_setValue = /* @__PURE__ */ (() => typeof GM_setValue != "undefined" ? GM_setValue : void 0)();
var _GM_xmlhttpRequest = /* @__PURE__ */ (() => typeof GM_xmlhttpRequest != "undefined" ? GM_xmlhttpRequest : void 0)();
const getI18nIndex = (lang2) => {
if (lang2.startsWith("zh")) return 1;
if (lang2.startsWith("ko")) return 2;
if (lang2.startsWith("es")) return 3;
return 0;
};
const lang = navigator.language;
const i18nIndex = getI18nIndex(lang);
class I18nValue extends Array {
constructor(langs) {
super(...langs);
}
get() {
return this[i18nIndex];
}
}
const i18nData = {
// page-helper
imageScale: [
"SCALE",
"缩放",
"배율",
"Escala"
],
config: [
"CONF",
"配置",
"설정",
"Ajustes"
],
chapters: [
"CHAPTERS",
"章节",
"챕터",
"Capítulos"
],
autoPagePlay: [
"PLAY",
"播放",
"재생",
"Reproducir"
],
autoPagePause: [
"PAUSE",
"暂停",
"일시 중지",
"Pausar"
],
collapse: [
"FOLD",
"收起",
"접기",
"Plegar"
],
// config panel number option
colCount: [
"Columns",
"每行数量",
"열 수",
"Columnas"
],
colCountTooltip: [
"The number of images per row in the thumbnail list. If the layout is Flow Vision, the final number of images per row will be influenced by the specific aspect ratio of the images.",
"缩略图列表的每行图片数量。如果布局为自适应视图,最终每行图片数量受图片的具体宽高比影响。",
"썸네일 목록에서 한 줄에 표시되는 이미지의 개수입니다. 레이아웃이 반응형인 경우, 최종 한 줄에 표시되는 이미지의 개수는 이미지의 구체적인 가로세로 비율에 영향을 받습니다.",
"El número de imágenes por fila en la lista de miniaturas. Si el diseño es adaptable, el número final de imágenes por fila estará influenciado por la proporción de aspecto específica de las imágenes."
],
rowHeight: [
"Row Height",
"每行高度",
"행 높이",
"Altura de fila"
],
rowHeightTooltip: [
"This option is only effective when the layout of the thumbnail list is Flow Vision. The reference height per row, along with the number of images per row, jointly influences the final display effect.",
"此项仅在缩略图列表的布局为自适应视图时有效。每行的参考高度,和每行数量共同影响最终的展示效果。",
"이 옵션은 썸네일 목록의 레이아웃이 반응형일 때만 유효합니다. 각 행의 기준 높이는 행당 이미지 개수와 함께 최종 표시 결과에 영향을 미칩니다.",
"Esta opción solo es efectiva cuando el diseño de la lista de miniaturas es adaptable. La altura de referencia por fila, junto con el número de imágenes por fila, influye en el efecto final de la visualización."
],
threads: [
"Preload Threads",
"最大同时加载",
"동시 로드 수",
"Hilos de pre-carga"
],
threadsTooltip: [
"Max Preload Threads",
"大图浏览时,每次滚动到下一张时,预加载的图片数量,大于1时体现为越看加载的图片越多,将提升浏览体验。",
"큰 이미지 모드에서 다음 이미지로 이동할 때 미리 로드할 이미지 수입니다.
이 값이 1보다 클 경우, 동시에 로드되는 이미지가 더 많아져서 사용 경험이 향상됩니다.",
"Hilos máximos de pre-carga"
],
downloadThreads: [
"Download Threads",
"最大同时下载",
"최대 동시 다운로드",
"Hilos de descarga"
],
downloadThreadsTooltip: [
"Max Download Threads, suggest: <5",
"下载模式下,同时加载的图片数量,建议小于等于5",
"다운로드 모드에서 동시에 다운로드할 이미지 수입니다. 5 이하로 설정하는 것이 좋습니다.",
"Hilos máximos de descarga, sugerido: <5"
],
paginationIMGCount: [
"Images Per Page",
"每页图片数量",
"페이지당 이미지 수",
"Imágenes por página"
],
paginationIMGCountTooltip: [
"In Pagination Read mode, the number of images displayed on each page",
"当阅读模式为翻页模式时,每页展示的图片数量",
"페이지 넘김 모드에서 각 페이지에 표시될 이미지 수입니다.",
"En el modo de lectura por paginación, el número de imágenes mostradas en cada página"
],
timeout: [
"Timeout(second)",
"超时时间(秒)",
"이미지 로딩 시도 시간 (초)",
"Tiempo de espera (segundos)"
],
preventScrollPageTime: [
"Min Paging Time",
"最小翻页时间",
"최소 페이지 넘김 시간",
"Tiempo mínimo de paginación"
],
preventScrollPageTimeTooltip: [
"In Pagination read mode, prevent immediate page flipping when scrolling to the bottom/top to improve the reading experience.
Set to 0 to disable this feature,
If set to less than 0, page-flipping via scrolling is always disabled, except for the spacebar.
measured in milliseconds.",
"当阅读模式为翻页模式时,滚动浏览时,阻止滚动到底部时立即翻页,提升阅读体验。
设置为0时则禁用此功能,单位为毫秒。
设置小于0时则永远禁止通过滚动的方式翻页。空格键除外。",
"페이지 넘김 모드에서 아래/위로 스크롤 시 너무 빨리 페이지가 넘어가는 것을 방지하여 읽기 경험을 개선합니다.
0으로 설정하면 이 기능이 비활성화됩니다.
0보다 작은 값으로 설정하면 단축키를 제외하고 스크롤을 통한 페이지 넘김이 항상 비활성화됩니다. (밀리초 단위)",
"En el modo de lectura por paginación, evita el cambio inmediato de página al desplazarse hacia el fondo o la parte superior para mejorar la experiencia de lectura.
Establezca en 0 para desactivar esta función,
Si se establece en menos de 0, el cambio de página mediante desplazamiento siempre está desactivado, excepto para la barra espaciadora.
Medido en milisegundos."
],
autoPageSpeed: [
"Auto Paging Speed",
"自动翻页速度",
"자동 페이지 넘김 속도",
"Velocidad de paginación automática"
],
autoPageSpeedTooltip: [
"In Pagination read mode, Auto Page Speed means how many seconds it takes to flip the page automatically.
In Continuous read mode, Auto Page Speed means the scrolling speed.",
"当阅读模式为翻页模式时,自动翻页速度表示为多少秒后翻页。
当阅读模式为连续模式时,自动翻页速度表示为滚动速度。",
"페이지 넘김 모드에서 자동 페이지 넘김 속도는 몇 초 후에 자동으로 페이지가 넘어갈지를 의미합니다.
연속 읽기 모드에서 자동 페이지 넘김 속도는 자동 스크롤 속도를 의미합니다.",
"En el modo de lectura por paginación, la velocidad de página automática indica cuántos segundos toma cambiar la página automáticamente.
En el modo de lectura continua, la velocidad de página automática indica la velocidad de desplazamiento."
],
scrollingDelta: [
"Scrolling Delta",
"滚动距离",
"Scrolling Delta",
"Scrolling Delta"
],
scrollingDeltaTooltip: [
"During non-native scrolling (custom keyboard scrolling, horizontal scrolling), the distance of each scroll.",
"非浏览器原生的滚动时(按键滚动、横向滚动),每次滚动的距离。",
"비기본 스크롤(사용자 정의 키보드 스크롤, 가로 스크롤) 중 각 스크롤의 거리입니다.",
"Durante el desplazamiento no nativo (desplazamiento con teclado personalizado, desplazamiento horizontal), la distancia de cada desplazamiento."
],
scrollingSpeed: [
"Scrolling Speed",
"滚动速度",
"스크롤 속도",
"Velocidad de desplazamiento"
],
scrollingSpeedTooltip: [
"During non-native scrolling (custom keyboard scrolling, horizontal scrolling), the speed of scrolling.",
"非浏览器原生的滚动时(按键滚动、横向滚动),滚动的速度。",
"비기본 스크롤(사용자 정의 키보드 스크롤, 가로 스크롤) 중 스크롤 속도입니다.",
"Durante el desplazamiento no nativo (desplazamiento con teclado personalizado, desplazamiento horizontal), la velocidad de desplazamiento."
],
// config panel boolean option
fetchOriginal: [
"Raw Image",
"最佳质量",
"원본 이미지",
"Imagen sin procesar"
],
fetchOriginalTooltip: [
"enable will download the original source, cost more traffic and quotas",
"启用后,将加载未经过压缩的原档文件,下载打包后的体积也与画廊所标体积一致。
注意:这将消耗更多的流量与配额,请酌情启用。",
"활성화하면 원본 파일이 다운로드됩니다. 더 많은 트래픽과 할당량이 소비됩니다.",
"Activar descargará la fuente original, lo que consumirá más tráfico y cuotas"
],
autoLoad: [
"Auto Load",
"自动加载",
"자동 로드",
"Carga automática"
],
autoLoadTooltip: [
"Automatically start loading images after entering this script's view",
"进入本脚本的浏览模式后,即使不浏览也会一张接一张的加载图片。直至所有图片加载完毕。",
"보기 모드에 진입하면, 사용자가 탐색 중이 아닐 때도 이미지가 하나씩 자동으로 로드됩니다. 모든 이미지가 로드될 때까지 계속됩니다.",
"Comience a cargar imágenes automáticamente después de ingresar a la vista de este script."
],
reversePages: [
"Reverse Pages",
"反向翻页",
"페이지 순서 뒤집기",
"Revertir páginas"
],
reversePagesTooltip: [
"Clicking on the side navigation, if enable then reverse paging, which is a reading style similar to Japanese manga where pages are read from right to left.",
"点击侧边导航时,是否反向翻页,反向翻页类似日本漫画那样的从右到左的阅读方式。",
"측면 내비게이션을 클릭했을 때 이미지들을 거꾸로 배치할 지 선택합니다. 일본 만화처럼 오른쪽에서 왼쪽으로 읽는 스타일의 이미지에 적용하면 좋습니다.",
"Hacer clic en la navegación lateral, si está habilitado, revertirá la paginación, que es un estilo de lectura similar al manga japonés, donde las páginas se leen de derecha a izquierda."
],
autoPlay: [
"Auto Page",
"自动翻页",
"자동 페이지 넘김",
"Paginación automática"
],
autoPlayTooltip: [
"Auto Page when entering the big image readmode.",
"当阅读大图时,开启自动播放模式。",
"이미지 크게 보기 모드에 들어가면 바로 자동 페이지 넘김을 활성화합니다.",
"Paginación automática al entrar en el modo de lectura de imagen grande."
],
autoLoadInBackground: [
"Keep Loading",
"后台加载",
"백그라운드 로딩",
"Sigue cargando"
],
autoLoadInBackgroundTooltip: [
"Keep Auto-Loading after the tab loses focus",
"当标签页失去焦点后保持自动加载。",
"사용자가 다른 창을 볼 때도 자동 로딩을 계속합니다.",
"Mantener la carga automática después de que la pestaña pierda el enfoque"
],
autoOpen: [
"Auto Open",
"自动展开",
"자동 이미지 열기",
"Abrir automáticamente"
],
autoOpenTooltip: [
"Automatically open after the gallery page is loaded",
"进入画廊页面后,自动展开阅读视图。",
"갤러리 페이지가 로드된 후 첫 페이지를 자동으로 엽니다.",
"Abrir automáticamente después de que la página de la galería se cargue"
],
autoCollapsePanel: [
"Auto Fold Control Panel",
"自动收起控制面板",
"설정 창 자동으로 닫기",
"Plegar automáticamente el panel de control"
],
autoCollapsePanelTooltip: [
"When the mouse is moved out of the control panel, the control panel will automatically fold. If disabled, the display of the control panel can only be toggled through the button on the control bar.",
"当鼠标移出控制面板时,自动收起控制面板。禁用此选项后,只能通过控制栏上的按钮切换控制面板的显示。",
"마우스가 설정 창이나 컨트롤 바를 벗어나면 설정 창이 자동으로 닫힙니다. 비활성화된 경우, 컨트롤 바의 버튼을 통해서만 창을 여닫을 수 있습니다.",
"Cuando el mouse se mueve fuera del panel de control, este se plegará automáticamente. Si está desactivado, la visualización del panel de control solo se puede alternar mediante el botón en la barra de control."
],
magnifier: [
"Magnifier",
"放大镜",
"돋보기",
"Lupa"
],
magnifierTooltip: [
"In the pagination reading mode, you can temporarily zoom in on an image by dragging it with the mouse click, and the image will follow the movement of the cursor.",
"在翻页阅读模式下,你可以通过鼠标左键拖动图片临时放大图片以及图片跟随指针移动。",
"Pagination 읽기 모드에서 마우스 클릭으로 이미지를 드래그하면 일시적으로 이미지를 확대할 수 있으며, 이미지가 마우스 커서의 움직임을 따라 이동합니다.",
"En el modo de lectura por paginación, puedes hacer un zoom temporal en una imagen arrastrándola con el clic del mouse, y la imagen seguirá el movimiento del cursor."
],
autoEnterBig: [
"Auto Big",
"自动大图",
"이미지 바로 보기",
"Auto Grande"
],
dragImageOut: [
"Drag Image Out",
"拖拽图片到外部",
"이미지를 밖으로 드래그",
"Arrastrar imagen hacia afuera"
],
dragImageOutTooltip: [
`Enabling this option will restore the browser's default dragging behavior for images (saving the image to the directory where it was dragged),
but will disable the magnifier and the ability to drag and move images.`,
`启用此项将恢复浏览器默认对图片的拖拽行为(保存图片到所拖拽到的目录),但会禁用放大镜功能以及拖拽移动图片位置的功能。`,
`이 옵션을 활성화하면 이미지에 대한 브라우저의 기본 드래그 동작(이미지를 드래그한 디렉토리에 이미지 저장)이 복원됩니다.
하지만 돋보기와 이미지 드래그 및 이동 기능은 비활성화됩니다.`,
`Habilitar esta opción restaurará el comportamiento de arrastre predeterminado del navegador para imágenes (guardando la imagen en el directorio donde fue arrastrada).
pero desactivará la lupa y la capacidad de arrastrar y mover imágenes.`
],
autoEnterBigTooltip: [
"Directly enter the Big image view when the script's entry is clicked or auto-opened",
"点击脚本入口或自动打开脚本后直接进入大图阅读视图。",
"이미지 뷰어가 열리면 즉시 큰 이미지 보기 모드로 전환됩니다.",
"Entrar directamente en la vista de imagen grande cuando se haga clic en la entrada del script o se abra automáticamente"
],
hdThumbnails: [
"HD Thumbnails",
"高清缩略图",
"HD 썸네일",
"Miniaturas HD"
],
hdThumbnailsTooltip: [
"When the large image is loaded, whether to resample a clearer image from the large image as a thumbnail, will affect performance.",
"当图片加载完毕后,是否从源图重新采样更加清晰的图片作为缩略图,此项会影响性能。",
"큰 이미지가 로드될 때 큰 이미지에서 보다 선명한 이미지를 썸네일로 리샘플링할지 여부가 성능에 영향을 미칩니다.",
"Cuando se carga la imagen grande, el hecho de volver a muestrear una imagen más clara de la imagen grande como miniatura afectará el rendimiento."
],
pixivJustCurrPage: [
"Pixiv Only Load Current Page",
"Pixiv仅加载当前作品页",
"Pixiv 현재 페이지만 로드",
"Pixiv: Cargar solo la página actual"
],
pixivJustCurrPageTooltip: [
`In Pixiv, if the current page is on a artwork page, only load the images from current page. Disable this option or the current page is on the artist's homepage, all images by that author will be loaded.
Note: You can continue loading all the remaining images by the author by scrolling on the page or pressing "Try Fetch Next Page" key after disabling this option.`,
"在Pixiv中,如果当前页是作品页则只加载当前页中的图片,如果该选项禁用或者当前页是作者主页,则加载该作者所有的作品。
注:你可以禁用该选项后,然后通过页面滚动或按下Shift+n来继续加载该作者所有的图片。",
'Pixiv에서 현재 페이지가 작품 페이지일 경우, 해당 페이지의 이미지들만 로드합니다. 이 옵션을 비활성화하거나 현재 페이지가 작가의 홈 페이지일 경우, 해당 작가의 모든 이미지를 로드합니다.
참고: 이 옵션을 비활성화한 후, 페이지를 스크롤하거나 "다음 페이지 로딩 재시도" 키를 눌러 작가의 나머지 이미지를 계속 로드할 수 있습니다.',
'En Pixiv, si la página actual está en una página de una obra, solo se cargarán las imágenes de la página actual. Desactive esta opción si la página actual está en la página de inicio del artista; en ese caso, se cargarán todas las imágenes de ese autor.
Nota: Puedes continuar cargando todas las imágenes restantes del autor desplazándote por la página o presionando la tecla "Intentar cargar la siguiente página" después de desactivar esta opción.'
],
// config panel select option
readMode: [
"Read Mode",
"阅读模式",
"읽기 모드",
"Modo de lectura"
],
readModeTooltip: [
"Switch to the next picture when scrolling, otherwise read continuously",
"滚动时切换到下一张图片,否则连续阅读",
"스크롤 시 다음 이미지로 전환하거나, 이미지들을 연속으로 배치합니다.",
"Cambiar a la siguiente imagen al desplazarse, de lo contrario, leer de manera continua"
],
stickyMouse: [
"Sticky Mouse",
"黏糊糊鼠标",
"마우스 고정",
"Mouse adhesivo"
],
stickyMouseTooltip: [
"In pagination reading mode, scroll a single image automatically by moving the mouse.",
"非连续阅读模式下,通过鼠标移动来自动滚动单张图片。",
"페이지 읽기 모드에서 마우스 커서를 움직여 하나의 이미지를 자동으로 스크롤합니다.",
"En el modo de lectura por paginación, desplaza una sola imagen automáticamente moviendo el mouse."
],
minifyPageHelper: [
"Minify Control Bar",
"最小化控制栏",
"컨트롤 바 최소화",
"Minimizar barra de control"
],
minifyPageHelperTooltip: [
"Minify Control Bar",
"最小化控制栏",
"언제 컨트롤 바를 최소화할지 선택합니다.",
"Minimizar barra de control"
],
hitomiFormat: [
"Hitomi Image Format",
"Hitomi 图片格式",
"Hitomi 이미지 형식",
"Formato de imagen de Hitomi"
],
hitomiFormatTooltip: [
"In Hitomi, Fetch images by the format.
if Auto then try Avif > Jxl > Webp, Requires Refresh",
"在Hitomi中的源图格式。
如果是Auto,则优先获取Avif > Jxl > Webp,修改后需要刷新生效。",
"Hitomi에서 이미지를 어떤 종류의 파일로 가져올 지 선택합니다.
Auto 설정 시 Avif > Jxl > Webp 순으로 시도하며, 변경 후 새로고침이 필요합니다.",
"En Hitomi, obtener imágenes por formato.
Si está en automático, intentará Avif > Jxl > Webp. Requiere actualización."
],
ehentaiTitlePrefer: [
"EHentai Prefer Title",
"EHentai标题语言",
"EHentai 선호 제목",
"Preferir título en EHentai"
],
ehentaiTitlePreferTooltip: [
"Many galleries have both an English/Romanized title and a title in Japanese script.
Which one do you want to use as the archive filename?",
"许多图库都同时拥有英文/罗马音标题和日文标题,
您希望下载时哪个作为文件名?",
"많은 갤러리가 영어/로마자 제목과 일본어 제목을 모두 가지고 있습니다.
어떤 것을 아카이브 파일 이름으로 사용할지 선택할 수 있습니다.",
"Muchas galerías tienen tanto un título en inglés/romanizado como un título en script japonés.
¿Cuál quieres usar como nombre de archivo?"
],
reverseMultipleImagesPost: [
"Descending Images In Post",
"反转推文图片顺序",
"포스트 이미지 내림차순 정렬",
"Imágenes descendentes en la publicación"
],
reverseMultipleImagesPostTooltip: [
"Reverse order for post with multiple images attatched",
"反转推文图片顺序",
"여러 이미지가 첨부된 포스트 내 이미지들의 순서를 역순으로 정렬합니다.",
"Orden inverso para publicaciones con múltiples imágenes adjuntas"
],
excludeVideo: [
"Exclude Videos",
"排除视频",
"비디오 제외",
"Excluir videos"
],
excludeVideoTooltip: [
"Exclude videos, now only applies to x.com and kemono.su.",
"排除视频,现在仅作用于x.com和kemono.su",
"비디오 제외, 현재 x.com과 kemono.su에만 적용됩니다.",
"Excluir videos, ahora solo se aplica a x.com y kemono.su."
],
filenameOrder: [
"Filename Order",
"文件名排序",
"파일명 순서",
"Orden de nombres de archivo"
],
filenameOrderTooltip: [
`Filename Sorting Rules for Downloaded Files:
Auto: Detect whether the original filenames are consistent with the reading order under natural sorting (Windows). If consistent, keep the original filenames; otherwise, prepend a number to the original filenames to ensure the correct order.
Numbers: Ignore the original filenames and rename the files directly according to the reading order.
Original: Keep only the original filenames without ensuring the reading order, which may result in overwriting files with the same name.
Alphabetically: Detect whether the original filenames are consistent with the reading order under alphabetical sorting (Linux). If consistent, keep the original filenames; otherwise, prepend a number to the original filenames to ensure the correct order. `,
`下载文件内的文件名排序规则:
Auto: 检测原文件名在自然排序(Windows)下是否与阅读顺序一致,如果一致保留原文件名,否则将在原文件名前添加序号以保证顺序。
Numbers: 忽略原文件名,直接以阅读顺序为文件命名。
Original: 只保留原文件名,不能保证阅读顺序以及同名文件覆盖。
Alphabetically: 检测原文件名在字母排序下(Linux)是否与阅读顺序一致,如果一致保留原文件名,否则将在原文件名前添加序号以保证顺序。`,
`다운로드 파일의 파일명 정렬 규칙:
Auto: 원본 파일명이 기본 정렬(Windows)에서 읽기 순서와 일치하는지 감지합니다. 일치하는 경우 원본 파일명을 유지하고, 그렇지 않으면 순서를 보장하기 위해 파일명 앞에 번호를 추가합니다.
Numbers: 원본 파일명을 무시하고 읽기 순서에 따라 파일명을 직접 지정합니다.
Original: 원본 파일명만 유지하며, 읽기 순서가 보장되지 않으며 동일한 이름의 파일이 덮어쓰일 수 있습니다.
Alphabetically: 원본 파일명이 알파벳 정렬(Linux)에서 읽기 순서와 일치하는지 감지합니다. 일치하는 경우 원본 파일명을 유지하고, 그렇지 않으면 순서를 보장하기 위해 파일명 앞에 번호를 추가합니다. `,
`Reglas de ordenamiento de nombres de archivos para archivos descargados:
Auto: Detecta si los nombres de archivo originales son consistentes con el orden de lectura bajo el ordenamiento natural (Windows). Si son consistentes, conserva los nombres de archivo originales; de lo contrario, antepone un número a los nombres originales para garantizar el orden correcto.
Numbers: Ignora los nombres de archivo originales y renombra los archivos directamente según el orden de lectura.
Original: Conserva únicamente los nombres de archivo originales sin garantizar el orden de lectura, lo que puede resultar en sobrescribir archivos con el mismo nombre.
Alphabetically: Detecta si los nombres de archivo originales son consistentes con el orden de lectura bajo el orden alfabético (Linux). Si son consistentes, conserva los nombres de archivo originales; de lo contrario, antepone un número a los nombres originales para garantizar el orden correcto. `
],
dragToMove: [
"Drag to Move the control bar",
"拖动移动",
"드래그해서 컨트롤 바 이동",
"Arrastra para mover la barra de control"
],
resetDownloaded: [
"Reset Downloaded Images",
"重置已下载的图片",
"다운로드한 이미지 초기화",
"Restablecer imágenes descargadas"
],
resetDownloadedConfirm: [
"You will reset Downloaded Images!",
"已下载的图片将会被重置为未下载!",
"이미지들은 다운로드하지 않은 상태로 초기화됩니다!",
"¡Vas a restablecer las imágenes descargadas!"
],
resetFailed: [
"Reset Failed Images",
"重置下载错误的图片",
"로딩 실패한 이미지 초기화",
"Restablecer imágenes fallidas"
],
showHelp: [
"Help",
"帮助",
"도움말",
"Ayuda"
],
showKeyboard: [
"Keyboard",
"快捷键",
"단축키",
"Teclado"
],
showSiteProfiles: [
"Site Profiles",
"站点配置",
"사이트 설정",
"Perfiles del sitio"
],
showStyleCustom: [
"Style",
"样式",
"스타일",
"Estilo"
],
controlBarStyleTooltip: [
"Click on an item to modify its display text, such as emoji or personalized text. Changes will take effect after restarting.",
"点击某项后修改其显示文本,比如emoji或个性文字,也许svg,重启后生效。",
"아이템을 클릭하여 이모티콘이나 텍스트 등을 수정할 수 있습니다. 변경 사항은 재시작 후 적용됩니다.",
"Haga clic en un elemento para modificar el texto que se muestra, como emoji o texto personalizado. Los cambios entrarán en vigor después de reiniciar."
],
letUsStar: [
"Let's Star",
"点星",
"별 눌러줘",
"Presiona la estrella"
],
// download panel
download: [
"DL",
"下载",
"다운로드",
"Descargar"
],
forceDownload: [
"Take Loaded",
"获取已下载的",
"다운로드된 이미지 가져오기",
"Tomar cargado"
],
downloadStart: [
"Start Download",
"开始下载",
"다운로드 시작",
"Comenzar descarga"
],
downloading: [
"Downloading...",
"下载中...",
"다운로드 중...",
"Descargando..."
],
downloadFailed: [
"Failed(Retry)",
"下载失败(重试)",
"실패(재시도)",
"Fallido(Reintentar)"
],
downloaded: [
"Downloaded",
"下载完成",
"다운로드 완료",
"Descargado"
],
packaging: [
"Packaging...",
"打包中...",
"압축 중...",
"Empaquetando..."
],
status: [
"Status",
"状态",
"상태",
"Estado"
],
selectChapters: [
"Chapters",
"章节",
"챕터",
"capítulos"
],
cherryPick: [
"Cherry Pick",
"范围选择",
"범위 선택",
"Seleccionar individualmente"
],
enable: [
"Enable",
"启用",
"활성화",
"Habilitar"
],
enableTooltips: [
"Enable the script on this site.",
"在此站点上启用本脚本的功能。",
"선택된 사이트에서만 스크립트를 활성화합니다.",
"Habilitar el script en este sitio."
],
enableAutoOpen: [
"Auto Open",
"自动打开",
"자동 크게 보기",
"Apertura automática"
],
enableAutoOpenTooltips: [
"Automatically open the interface of this script when entering the corresponding page.",
"当进入对应的生效页面后,自动打开本脚本界面。",
"해당 페이지에 들어갈 때 이 스크립트의 인터페이스를 자동으로 엽니다.",
"Abrir automáticamente la interfaz de este script al ingresar a la página correspondiente."
],
enableFlowVision: [
"Flow Vision",
"自适应视图",
"Flow Vision",
"Flow Vision"
],
enableFlowVisionTooltips: [
`Enable a new thumbnail list layout where the images in each row have uniform height, but the number of images per row is automatically adjusted.
The overall appearance is more compact and comfortable, suitable for illustration-based websites with irregular image aspect ratios.
Note: Since some websites cannot retrieve image aspect ratio information, the effect may be impacted.`,
`启用一种新的缩略图列表布局,使每行的图片高度一致,但自动分配每行的图片数量。
整体看起来更紧凑舒适,适合图片宽高比不规则的插画类站点。
注意:由于一些站点无法提取得知图片的宽高比,因此效果可能会受到影响。`,
`새로운 썸네일 리스트 레이아웃을 활성화하여 각각의 행에 있는 이미지들이 동일한 높이를 가지도록 합니다. 대신 행당 이미지의 수는 자동으로 조정됩니다.
전체적인 외관은 더 간결하고 편안하며, 불규칙한 이미지 비율을 가진 일러스트 기반 웹사이트에 적합합니다.
참고: 일부 웹사이트는 이미지 비율 정보를 가져올 수 없으므로, 이로 인해 효과에 영향을 받을 수 있습니다.`,
`Activar un nuevo diseño de lista de miniaturas donde las imágenes en cada fila tienen altura uniforme, pero el número de imágenes por fila se ajusta automáticamente.
La apariencia general es más compacta y cómoda, adecuada para sitios web basados en ilustraciones con relaciones de aspecto de imagen irregulares.
Nota: Dado que algunos sitios web no pueden recuperar la información de la relación de aspecto de las imágenes, el efecto puede verse afectado.`
],
addRegexp: [
"Add Work URL Regexp",
"添加生效地址规则",
"URL 정규식 추가",
"Agregar expresión regular de URL"
],
failFetchReason1: [
"Refused to connect {{domain}}(origin image url), Please check the domain blacklist: Tampermonkey > Comic Looms > Settings > XHR Security > User domain blacklist",
"被拒绝连接{{domain}}(大图地址),请检查域名黑名单: Tampermonkey(篡改猴) > 漫画织机 > 设置 > XHR Security > User domain blacklist",
"Refused to connect {{domain}}(origin image url), Please check the domain blacklist: Tampermonkey > Comic Looms > Settings > XHR Security > User domain blacklist",
"Refused to connect {{domain}}(origin image url), Please check the domain blacklist: Tampermonkey > Comic Looms > Settings > XHR Security > User domain blacklist"
],
help: [
`
The script typically activates on gallery homepages or artist homepages. For example, on E-Hentai, it activates on the gallery detail page, or on Twitter, it activates on the user's homepage or tweets.
When active, a <🎑> icon will appear at the bottom left of the page. Click it to enter the script's reading interface.
These issues are caused by Twitter|X's Content Security Policy (CSP), which disables URL mutation detection and the Zip creation functionality.
You can modify Twitter|X's response header Content-Security-Policy to Content-Security-Policy: object-src '*' using other extensions.
For example, in the extension Header Editor, click the Add button:
Yes! At the bottom of the configuration panel, there's a Drag to Move option. Drag the icon to reposition the control bar anywhere on the page.
Yes! There is an Auto Open option in the configuration panel. Enable it to activate this feature.
There are several ways to zoom images in big image reading mode:
In CONF > Style, modify or add: .ehvp-root { --ehvp-big-images-gap: 2px; }
In the thumbnail list interface, simply type the desired page number on your keyboard (without any prompt) and press Enter or your custom shortcuts.
The thumbnail list interface is the script's most important feature, allowing you to quickly get an overview of the entire gallery.
Thumbnails are also lazy-loaded, typically loading about 20 images, which is comparable to or even fewer requests than normal browsing.
Pagination is also lazy-loaded, meaning not all gallery pages load at once. Only when you scroll near the bottom does the next page load.
Don't worry about generating a lot of requests by quickly scrolling through the thumbnail list; the script is designed to handle this efficiently.
By default, the script automatically and slowly loads large images one by one.
You can still click any thumbnail to start loading and reading from that point, at which time auto-loading will stop and pre-load 3 images from the reading position.
Just like the thumbnail list, you don't need to worry about generating a lot of loading requests by fast scrolling.
Downloading is integrated with large image loading. When you finish browsing a gallery and want to save and download the images, you can click Start Download in the download panel. don't worry about re-downloading already loaded images.
You can also directly click Start Download in the download panel without reading.
Alternatively, click the Take Loaded button in the download panel if some images consistently fail to load. This will save the images that have already been loaded.
The download panel's status indicators provide a clear view of image loading progress.
Note: When the download file size exceeds 1.2GB, split compression will be automatically enabled. If you encounter errors while extracting the files, please update your extraction software or use 7-Zip.
Yes, the download panel has an option to select the download range(Cherry Pick), which applies to downloading, auto-loading, and pre-loading.
Even if an image is excluded from the download range, you can still click its thumbnail to view it, which will load the corresponding large image.
In the thumbnail list, you can use some hotkeys to select images:
In addition, there are several other methods:
Yes! There's a Keyboard button at the bottom of the configuration panel. Click it to view or configure keyboard operations.
You can even configure it for one-handed full keyboard operation, freeing up your other hand!
There's a Site Profiles button at the bottom of the configuration panel. Click it to exclude certain sites from auto-opening. For example, Twitter or Booru-type sites.
There's a Site Profiles button at the bottom of the configuration panel to exclude specific sites. Once excluded, the script will no longer activate on those sites.
To re-enable a site, you need to do so from a site that hasn't been excluded.
Give me a star on Github or a good review on Greasyfork.
Please do not review on Greasyfork, as its notification system cannot track subsequent feedback. Many people leave an issue and never back. Report issues here: issue
Click the Help button at the bottom of the configuration panel.
`, `脚本一般生效于画廊详情页或画家的主页或作品页。比如在E-Hentai上,生效于画廊详情页,或者在Twitter上,生效于推主的主页或推文。
生效时,在页面的左下方会有一个<🎑>图标,点击后即可进入脚本的阅读界面。
这些问题是由于Twitter|X的内容安全策略(CSP)导致,它使URL的变动检测和创建Zip功能失效。
可以通过其他拓展修改Twitter|X的响应头Content-Security-Policy为Content-Security-Policy: object-src '*'
例如在拓展Header Editor中,点击添加按钮:
可以!在配置面板的下方,有一个拖拽移动的选项,对着图标进行拖动,你可以将控制栏移动到页面上的任意位置。
可以!在配置面板中,有一个自动打开的选项,启用即可。
有几种方式可以在大图阅读模式中缩放图片:
在CONF > Style中,修改或添加 .ehvp-root { --ehvp-big-images-gap: 2px; }
在缩略图列表界面中,直接在键盘上输入数字(没有提示),然后按下回车或自定义的快捷键。
缩略图列表是脚本最重要的特性,可以让你快速地了解整个画廊的情况。
并且缩略图也是延迟加载的,通常会加载20张左右,与正常浏览所发出的请求相当,甚至更低。
并且分页也是延迟加载的,并不会一次性加载画廊的所有分页,只有滚动到接近底部时,才会加载下一页。
不用担心因为在缩略图列表中快速滚动而导致发出大量的请求,脚本充分考虑到了这一点。
默认配置下,脚本会自动且缓慢地一张接一张地加载大图。
你仍然可以点击任意位置的缩略图,并从该处开始加载并阅读,此时会自动加载会停止并从阅读的位置预加载3张图片。
同缩略图列表一样,无需担心因为快速滚动而导致发出大量的加载请求。
下载与大图加载是一体的,当你浏览完画廊时,突然想起来要保存下载,此时你可以在下载面板中点击开始下载,不必担心会重复下载已经加载过的图片。
当然你也可以不浏览,直接在下载面板中点击开始下载。
或者点击下载面板中的获取已下载的按钮,当一些图片总是加载失败的时候,你可以使用此功能来保存已经加载过的图片。
通过下载面板中的状态可以直观地看到图片加载的情况。
注意:当下载文件大小超过1.2G后,会自动启用分卷压缩。当使用解压软件解压出错时,请更新解压软件或使用7-Zip。
可以,在下载面板中有选择下载范围的功能,该功能对下载、自动加载、预加载都生效。
另外,如果一张图片被排除在下载范围之外,你仍然可以点击该图片的缩略图进行浏览,这会加载对应的大图。
在缩略图列表中使用一些快捷键可以进行图片的挑选。
除此之外还有几种方式:
可以!在配置面板的下方,有一个快捷键按钮,点击后可以查看键盘操作,或进行配置。
甚至可以配置为单手全键盘操作,解放另一只手!
在配置面板的下方,有一个站点配置按钮,点击后可以对一些不适合自动打开的网站进行排除。比如Twitter或Booru类的网站。
在配置面板的下方,有一个站点配置的按钮,可对一些站点进行排除,排除后脚本不会再生效。
如果想重新启用该站点,需要在其他未排除的站点中启用被禁用的站点。
给我Github星星,或者Greasyfork上好评。
请勿在Greasyfork上反馈问题,因为该站点的通知系统无法跟踪后续的反馈。很多人只是留下一个问题,再也没有回来过。 请在此反馈问题: issue
在配置面板的下方,点击帮助按钮。
`, `이 스크립트는 주로 갤러리 홈페이지나 아티스트 홈페이지에서 활성화됩니다. 예를 들어, E-Hentai에서는 갤러리 상세 페이지에서, Twitter에서는 사용자의 홈 또는 트윗에서, arca.live에서는 작성된 글에서 활성화됩니다.
스크립트가 활성화되면 페이지의 왼쪽 하단에 <🎑> 아이콘이 나타납니다. 이 아이콘을 클릭하면 스크립트의 읽기 화면으로 진입할 수 있습니다.
네! 설정 패널 하단에 드래그해서 컨트롤 바 이동 옵션이 있습니다. 이 아이콘을 드래그하여 페이지 내 원하는 위치로 컨트롤 바를 이동할 수 있습니다.
네! 설정 패널에서 자동으로 이미지 열기 옵션을 활성화하면 이 기능이 켜집니다.
큰 이미지 보기 모드에서 이미지를 확대하는 방법은 여러 가지가 있습니다:
CONF > Style에서 다음을 수정하거나 추가하세요: .ehvp-root { --ehvp-big-images-gap: 2px; }
썸네일 리스트 화면에서 원하는 페이지 번호를 키보드로 입력하고 Enter 키나 사용자 지정 단축키를 누르세요.
썸네일 리스트 화면은 스크립트의 가장 중요한 기능으로, 전체 갤러리를 빠르게 둘러볼 수 있게 해줍니다.
썸네일은 지연 로딩되며, 일반적으로 약 20개의 이미지를 로드합니다. 이는 일반적인 브라우징보다 요청 수가 적거나 비슷한 정도입니다.
페이징 또한 지연 로딩됩니다. 즉, 모든 갤러리의 페이지가 한 번에 로드되지 않습니다. 하단 근처로 스크롤할 때만 다음 페이지가 로드됩니다.
썸네일 리스트를 빠르게 스크롤해도 괜찮습니다. 이 스크립트는 그런 경우에도 많은 요청이 발생하지 않도록 효율적으로 설계되어 있습니다.
기본적으로 스크립트는 큰 이미지를 하나씩 자동으로 천천히 로드합니다.
원하는 썸네일을 클릭하여 그 지점에서 로딩 및 읽기를 시작할 수 있으며, 이때 자동 로딩이 중지되고 읽기 위치에서 3개의 이미지를 사전 로딩합니다.
썸네일 리스트와 마찬가지로 빠르게 스크롤해도 많은 로딩 요청이 발생하지 않도록 설계되어 있으니 걱정하지 않으셔도 됩니다.
다운로드는 큰 이미지 로딩과 통합되어 있습니다. 갤러리를 모두 본 후 이미지를 저장하고 다운로드하려면 다운로드 패널에서 다운로드 시작을 클릭하세요. 이미 로드된 이미지를 다시 다운로드하는 것에 대해서는 걱정 안 하셔도 됩니다.
이미지를 보지 않고 바로 다운로드 패널에서 다운로드 시작을 클릭할 수도 있습니다.
또한 일부 이미지가 로드되지 않을 때는 다운로드 패널에서 이미 다운로드한 이미지 가져오기 버튼을 클릭하여 이미 로드된 이미지를 저장할 수 있습니다.
다운로드 패널의 상태 표시기를 통해 이미지 로딩 진행 상황을 명확히 볼 수 있습니다.
참고: 다운로드 파일 크기가 1.2GB를 초과할 경우, 분할 압축이 자동으로 활성화됩니다. 파일을 추출하는 동안 오류가 발생하면 추출 소프트웨어를 업데이트하거나 7-Zip을 사용하세요.
네, 다운로드 패널에는 다운로드 범위를 선택할 수 있는 옵션(Cherry Pick)이 있으며, 이는 다운로드, 자동 로딩 및 사전 로딩에 적용됩니다.
다운로드 범위에서 제외된 이미지라도 썸네일을 클릭하여 해당 큰 이미지를 로드할 수 있습니다.
썸네일 리스트에서 다음 핫키를 사용하여 이미지를 선택할 수 있습니다:
추가적으로 몇 가지 방법이 더 있습니다:
네! 설정 패널 하단에 단축키 버튼이 있습니다. 이 버튼을 클릭하여 키보드 조작을 확인하거나 설정할 수 있습니다.
한 손으로 모든 키보드 조작을 할 수 있도록 설정할 수도 있어, 다른 손을 자유롭게 쓸 수 있습니다!
설정 패널 하단에 있는 사이트 설정 버튼을 클릭하여 특정 사이트에서 자동 열기를 제외할 수 있습니다. 예를 들어, Twitter나 Booru 타입의 사이트를 제외할 수 있습니다.
설정 패널 하단의 사이트 설정 버튼을 클릭하여 특정 사이트를 제외할 수 있습니다. 제외된 사이트에서는 더 이상 스크립트가 활성화되지 않습니다.
사이트를 다시 활성화하려면 제외되지 않은 사이트에서 설정해야 합니다.
Github에 별을 주시거나, Greasyfork에서 좋은 리뷰를 남겨주세요.
단, Greasyfork에 버그 제보 내용의 리뷰를 남기지 마세요. 해당 플랫폼의 알림 시스템이 후속 피드백을 추적할 수 없습니다. 많은 사람들이 문제를 제기하고 다시 돌아오지 않습니다.
문제는 여기에 보고해 주세요: 이슈
설정 패널 하단에 있는 도움말 버튼을 클릭하세요.
이 스크립트는 단순한 jQuery(구형 스크립트)에서부터 최첨단 Vue.js 프레임워크에 이르기까지 매우 다양한 웹 기술에서 작동합니다. 이 스크립트는 해당 기술들을 해킹하지 않고도 상호작용할 수 있도록 최적화되어 있습니다.
설정 패널의 자동 저장 및 사이트별 설정 기능은 스크립트의 본체 코드에 저장되지 않으며, 스크립트에서 수집하는 정보는 로컬 컴퓨터에만 저장됩니다.
또한 이 스크립트는 많은 이미지를 처리할 수 있도록 효율적으로 설계되었습니다. 이미지 로딩 시점에서는 브라우저에 의존하며, 이미지 관련 데이터는 사용자 시스템의 메모리로 직접 로드됩니다. 이는 데이터 전송량과 서버 요청 수를 줄이면서도 빠르고 유연한 이미징을 가능하게 합니다.
이 스크립트는 웹페이지의 HTML 구조와 상호작용하기 때문에 페이지가 변경될 경우(예: 개발자가 업데이트를 하거나 광고를 삽입할 때) 예상대로 작동하지 않을 수 있습니다. 이 경우, 브라우저 콘솔을 열어 오류 메시지를 확인하세요. 오류 메시지가 표시되면 GitHub 이슈 섹션에 보고해 주세요.
설정 패널에서 다양한 설정 옵션을 사용할 수 있으며, 각 설정은 사용자 환경을 최적화하는 데 도움이 됩니다. 스크립트가 의도대로 작동하지 않는 경우 GitHub 이슈에서 해결 방법을 찾아보세요.
`, `El script generalmente se activa en las páginas principales de galerías o en las páginas principales de artistas. Por ejemplo, en E-Hentai, se activa en la página de detalles de la galería, o en Twitter, se activa en la página principal del usuario o en los tweets.p>
Cuando esté activo, aparecerá un ícono de <🎑> en la parte inferior izquierda de la página.
¡Sí! En la parte inferior del panel de configuración, hay una opción de Arrastrar para mover. Arrastra el ícono para reposicionar la barra de control en cualquier parte de la página.
¡Sí! Hay una opción de Apertura Automática en el panel de configuración. Actívala para habilitar esta función.
Hay varias formas de hacer zoom en las imágenes en el modo de lectura de imágenes grandes:
En CONF > Style, modifique o añada: .ehvp-root { --ehvp-big-images-gap: 2px; }
En la interfaz de lista de miniaturas, simplemente escribe el número de página deseado en tu teclado (sin necesidad de un aviso) y presiona Enter o utiliza tus atajos personalizados.
La interfaz de lista de miniaturas es la característica más importante del script, ya que te permite obtener rápidamente una vista general de toda la galería.
Las miniaturas se cargan de forma diferida, normalmente cargando alrededor de 20 imágenes, lo que es comparable o incluso implica menos solicitudes que la navegación normal.
La paginación también se carga de manera diferida, lo que significa que no todas las páginas de la galería se cargan a la vez. Solo cuando te acercas al final de la página, se carga la siguiente.
No te preocupes por generar muchas solicitudes al desplazarte rápidamente por la lista de miniaturas; el script está diseñado para manejar esto de manera eficiente.
Por defecto, el script carga automáticamente y de manera gradual las imágenes grandes una por una.
Aún puedes hacer clic en cualquier miniatura para comenzar a cargar y leer desde ese punto, momento en el cual la carga automática se detendrá y se pre-cargarán 3 imágenes desde la posición de lectura.
Al igual que con la lista de miniaturas, no necesitas preocuparte por generar muchas solicitudes de carga al desplazarte rápidamente.
La descarga está integrada con la carga de imágenes grandes. Cuando termines de navegar por una galería y quieras guardar y descargar las imágenes, puedes hacer clic en Iniciar Descarga en el panel de descargas. No te preocupes por volver a descargar las imágenes ya cargadas.
También puedes hacer clic directamente en Iniciar Descarga en el panel de descargas sin necesidad de leer.
Alternativamente, haz clic en el botón Tomar Cargadas en el panel de descargas si algunas imágenes no se cargan consistentemente. Esto guardará las imágenes que ya se han cargado.
Los indicadores de estado del panel de descargas proporcionan una visión clara del progreso de la carga de imágenes.
Nota: Cuando el tamaño del archivo de descarga supere los 1.2 GB, se habilitará automáticamente la compresión dividida. Si encuentras errores al extraer los archivos, por favor actualiza tu software de extracción o usa 7-Zip.
Sí, el panel de descargas tiene una opción para seleccionar el rango de descarga (Cherry Pick), que se aplica a la descarga, carga automática y carga anticipada.
Incluso si una imagen está excluida del rango de descarga, aún puedes hacer clic en su miniatura para verla, lo que cargará la imagen grande correspondiente.
En la lista de miniaturas, puedes usar algunas teclas de acceso rápido para seleccionar imágenes:
Además, hay otros métodos:
¡Sí! Hay un botón del Teclado en la parte inferior del panel de configuración. Haz clic en él para ver o configurar las operaciones del teclado.
¡Incluso puedes configurarlo para operar con una sola mano, liberando así tu otra mano!
Hay un botón de Perfiles de Sitio en la parte inferior del panel de configuración. Haz clic en él para excluir ciertos sitios de la apertura automática. Por ejemplo, sitios como Twitter o de tipo Booru.
Hay un botón de Perfiles de Sitio en la parte inferior del panel de configuración para excluir sitios específicos. Una vez excluidos, el script ya no se activará en esos sitios.
Para volver a habilitar un sitio, necesitas hacerlo desde un sitio que no haya sido excluido.
Déjame una estrella en Github o una buena reseña en Greasyfork.
Por favor, no dejes reseñas en Greasyfork, ya que su sistema de notificaciones no puede rastrear comentarios posteriores. Muchas personas dejan un problema y nunca vuelven. Reporta problemas aquí: issue
Haz clic en el botón de Ayuda en la parte inferior del panel de configuración.
` ] }; const kbInFullViewGridData = { "open-full-view-grid": [ "Enter Read Mode", "进入阅读模式", "읽기 모드 시작", "Entrar en modo de lectura" ], "start-download": [ "Start Download", "开始下载", "다운로드 시작", "Iniciar Descarga" ], "step-image-prev": [ "Go Prev Image", "切换到上一张图片", "이전 이미지", "Ir a la imagen anterior" ], "step-image-next": [ "Go Next Image", "切换到下一张图片", "다음 이미지", "Ir a la imagen siguiente" ], "exit-big-image-mode": [ "Exit Big Image Mode", "退出大图模式", "이미지 크게 보기 종료", "Salir del modo de imagen grande" ], "step-to-first-image": [ "Go First Image", "跳转到第一张图片", "첫 이미지로 이동", "Ir a la primera imagen" ], "step-to-last-image": [ "Go Last Image", "跳转到最后一张图片", "마지막 이미지로 이동", "Ir a la última imagen" ], "scale-image-increase": [ "Increase Image Scale", "放大图片", "이미지 확대", "Aumentar la escala de la imagen" ], "scale-image-decrease": [ "Decrease Image Scale", "缩小图片", "이미지 축소", "Disminuir la escala de la imagen" ], "scroll-image-up": [ "Scroll Image Up (Please Keep Default Keys)", "向上滚动图片 (请保留默认按键)", "이미지 위로 스크롤 (기본 키는 그대로 두십시오)", "Desplazar la imagen hacia arriba (Por favor, mantener las teclas predeterminadas)" ], "scroll-image-down": [ "Scroll Image Down (Please Keep Default Keys)", "向下滚动图片 (请保留默认按键)", "이미지 아래로 스크롤 (기본 키는 그대로 두십시오)", "Desplazar la imagen hacia abajo (Por favor, mantener las teclas predeterminadas)" ], "toggle-auto-play": [ "Toggle Auto Play", "切换自动播放", "자동 재생 시작/중지", "Alternar reproducción automática" ], "round-read-mode": [ "Switch Reading mode (Loop)", "切换阅读模式(循环)", "읽기 모드 전환(루프)", "Cambiar modo de lectura (bucle)" ], "toggle-reverse-pages": [ "Toggle Pages Reverse", "切换阅读方向", "페이지 반전 전환", "Alternar páginas hacia atrás" ], "rotate-image": [ "Rotate Image", "旋转图片", "이미지 회전", "Girar imagen" ], "cherry-pick-current": [ "Cherry Pick Current Images", "选择当前图片", "체리픽 현재 이미지", "Imágenes actuales de Cherry Pick" ], "exclude-current": [ "Exclude current images", "排除当前图片", "현재 이미지 제외", "Excluir imágenes actuales" ], "open-big-image-mode": [ "Enter Big Image Mode", "进入大图阅读模式", "이미지 크게 보기", "Entrar al modo de imagen grande" ], "pause-auto-load-temporarily": [ "Pause Auto Load Temporarily", "临时停止自动加载", "자동 이미지 로딩 일시 중지", "Pausar carga automática temporalmente" ], "exit-full-view-grid": [ "Exit Read Mode", "退出阅读模式", "읽기 모드 종료", "Salir del modo de lectura" ], "columns-increase": [ "Increase Columns ", "增加每行数量", "열 수 늘리기", "Aumentar columnas" ], "columns-decrease": [ "Decrease Columns ", "减少每行数量", "열 수 줄이기", "Disminuir columnas" ], "retry-fetch-next-page": [ "Try Fetch Next Page", "重新加载下一分页", "다음 페이지 로딩 재시도", "Intentar cargar la siguiente página" ], "resize-flow-vision": [ "Resize Thumbnail Grid Layout", "Resize Thumbnail Grid Layout", "Resize Thumbnail Grid Layout", "Resize Thumbnail Grid Layout" ] }; function convert(data) { const entries = Object.entries(data); const ret = entries.reduce((prev, [k, v]) => { prev[k] = new I18nValue(v); return prev; }, {}); return ret; } const i18n = { ...convert(i18nData), keyboard: convert(kbInFullViewGridData) }; const moonViewCeremony = `<🎑>`; const zoomIcon = `⇱⇲`; const icons = { moonViewCeremony, zoomIcon }; function uuid() { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { const r = Math.random() * 16 | 0, v = c == "x" ? r : r & 3 | 8; return v.toString(16); }); } function transactionId() { return window.btoa(uuid()); } function b64EncodeUnicode(str) { return btoa(encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function(_match, p1) { return String.fromCharCode(parseInt(p1, 16)); })); } const IS_MOBILE = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i.test(navigator.userAgent); function defaultColumns() { const screenWidth = window.screen.width; return screenWidth > 2500 ? 7 : screenWidth > 1900 ? 6 : screenWidth > 700 ? 5 : 3; } function defaultRowHeight() { const vh = window.screen.availHeight; return Math.floor(vh / 3.4); } function defaultConf() { return { colCount: defaultColumns(), rowHeight: defaultRowHeight(), readMode: "pagination", autoLoad: true, fetchOriginal: false, restartIdleLoader: 2e3, threads: 3, downloadThreads: 4, timeout: 10, version: CONF_VERSION, debug: true, first: true, reversePages: false, pageHelperAbTop: "unset", pageHelperAbLeft: "20px", pageHelperAbBottom: "20px", pageHelperAbRight: "unset", imgScale: 100, defaultImgScaleModeC: 60, autoPageSpeed: 5, // pagination readmode = 5, continuous readmode = 1 autoPlay: false, hdThumbnails: false, filenameTemplate: "{number}-{title}", preventScrollPageTime: 100, archiveVolumeSize: 1200, pixivConvertTo: "GIF", autoCollapsePanel: true, minifyPageHelper: IS_MOBILE ? "never" : "inBigMode", keyboards: { inBigImageMode: {}, inFullViewGrid: {}, inMain: {} }, siteProfiles: {}, muted: false, volume: 50, mcInSites: ["18comic"], paginationIMGCount: 1, hitomiFormat: "auto", autoOpen: false, autoLoadInBackground: true, reverseMultipleImagesPost: true, ehentaiTitlePrefer: "japanese", scrollingDelta: 300, scrollingSpeed: 20, id: uuid(), configPatchVersion: 0, displayText: {}, customStyle: "", magnifier: false, autoEnterBig: false, pixivJustCurrPage: false, filenameOrder: "auto", dragImageOut: false, excludeVideo: false }; } const CONF_VERSION = "4.4.0"; const CONFIG_KEY = "ehvh_cfg_"; function getStorageMethod() { if (typeof _GM_getValue === "function" && typeof _GM_setValue === "function") { return { setItem: (key, value) => _GM_setValue(key, value), getItem: (key) => _GM_getValue(key) }; } else if (typeof localStorage !== "undefined") { return { setItem: (key, value) => localStorage.setItem(key, value), getItem: (key) => localStorage.getItem(key) }; } else { throw new Error("No supported storage method found"); } } const storage = getStorageMethod(); function getConf() { const cfgStr = storage.getItem(CONFIG_KEY); if (cfgStr) { const cfg2 = JSON.parse(cfgStr); if (cfg2.version === CONF_VERSION) { return confHealthCheck(cfg2); } } const cfg = defaultConf(); saveConf(cfg); return cfg; } function confHealthCheck(cf) { let changed = false; const defa = defaultConf(); const defaKeys = Object.keys(defa); defaKeys.forEach((key) => { if (cf[key] === void 0) { cf[key] = defa[key]; changed = true; } }); const cfKeys = Object.keys(cf); for (const k of cfKeys) { if (!defaKeys.includes(k)) { delete cf[k]; changed = true; } } ["pageHelperAbTop", "pageHelperAbLeft", "pageHelperAbBottom", "pageHelperAbRight"].forEach((key) => { if (cf[key] !== "unset") { const pos = parseInt(cf[key]); const screenLimit = key.endsWith("Right") || key.endsWith("Left") ? window.screen.width : window.screen.height; if (isNaN(pos) || pos < 5 || pos > screenLimit) { cf[key] = "5px"; changed = true; } } }); if (!["pagination", "continuous", "horizontal"].includes(cf.readMode)) { cf.readMode = "pagination"; changed = true; } if (cf.imgScale === void 0 || isNaN(cf.imgScale) || cf.imgScale === 0) { cf.imgScale = cf.readMode === "continuous" ? cf.defaultImgScaleModeC : 100; changed = true; } const newCf = patchConfig(cf); if (newCf) { cf = newCf; changed = true; } if (changed) { saveConf(cf); } return cf; } function patchConfig(cf) { let changed = false; if (cf.configPatchVersion < 8) { cf.siteProfiles = {}; cf.configPatchVersion = 8; cf.colCount = defaultColumns(); cf.keyboards = { inBigImageMode: {}, inFullViewGrid: {}, inMain: {} }; changed = true; } if (cf.configPatchVersion < 9) { delete cf.siteProfiles["rule34"]; cf.configPatchVersion = 9; changed = true; } if (cf.configPatchVersion < 10) { cf.customStyle = ""; cf.configPatchVersion = 10; changed = true; } return changed ? cf : null; } function saveConf(c) { storage.setItem(CONFIG_KEY, JSON.stringify(c)); } const conf = getConf(); const transient = { imgSrcCSP: false, originalPolicy: "" }; const ConfigItems = [ { key: "colCount", typ: "number" }, { key: "rowHeight", typ: "number" }, { key: "threads", typ: "number" }, { key: "downloadThreads", typ: "number" }, { key: "paginationIMGCount", typ: "number" }, { key: "timeout", typ: "number" }, { key: "preventScrollPageTime", typ: "number" }, { key: "autoPageSpeed", typ: "number" }, { key: "scrollingDelta", typ: "number" }, { key: "scrollingSpeed", typ: "number" }, { key: "fetchOriginal", typ: "boolean", gridColumnRange: [1, 6] }, { key: "autoLoad", typ: "boolean", gridColumnRange: [6, 11] }, { key: "reversePages", typ: "boolean", gridColumnRange: [1, 6] }, { key: "autoPlay", typ: "boolean", gridColumnRange: [6, 11] }, { key: "autoLoadInBackground", typ: "boolean", gridColumnRange: [1, 6] }, { key: "autoOpen", typ: "boolean", gridColumnRange: [6, 11] }, { key: "magnifier", typ: "boolean", gridColumnRange: [1, 6] }, { key: "autoEnterBig", typ: "boolean", gridColumnRange: [6, 11] }, { key: "dragImageOut", typ: "boolean", gridColumnRange: [1, 6] }, { key: "hdThumbnails", typ: "boolean", gridColumnRange: [6, 11] }, { key: "autoCollapsePanel", typ: "boolean", gridColumnRange: [1, 11] }, { key: "pixivJustCurrPage", typ: "boolean", gridColumnRange: [1, 11], displayInSite: /pixiv.net/ }, { key: "reverseMultipleImagesPost", typ: "boolean", gridColumnRange: [1, 11], displayInSite: /(x.com|twitter.com)\// }, { key: "excludeVideo", typ: "boolean", gridColumnRange: [1, 11], displayInSite: /(x.com|twitter.com|kemono.su)\// }, { key: "readMode", typ: "select", options: [ { value: "pagination", display: "Pagination" }, { value: "continuous", display: "Continuous" }, { value: "horizontal", display: "Horizontal" } ] }, { key: "minifyPageHelper", typ: "select", options: [ { value: "always", display: "Always" }, { value: "inBigMode", display: "InBigMode" }, { value: "never", display: "Never" } ] }, { key: "hitomiFormat", typ: "select", options: [ { value: "auto", display: "Auto" }, { value: "avif", display: "Avif" }, { value: "webp", display: "Webp" }, { value: "jxl", display: "Jxl" } ], displayInSite: /hitomi.la\// }, { key: "ehentaiTitlePrefer", typ: "select", options: [ { value: "english", display: "English" }, { value: "japanese", display: "Japanese" } ], displayInSite: /e[-x]hentai(.*)?.(org|onion)\// }, { key: "filenameOrder", typ: "select", options: [ { value: "auto", display: "Auto" }, { value: "numbers", display: "Numbers" }, { value: "original", display: "Original" }, { value: "alphabetically", display: "Alphabetically" } ] } ]; const DEFAULT_DISPLAY_TEXT = { entry: icons.moonViewCeremony, collapse: i18n.collapse.get(), fin: "FIN", autoPagePlay: i18n.autoPagePlay.get(), autoPagePause: i18n.autoPagePause.get(), config: i18n.config.get(), download: i18n.download.get(), chapters: i18n.chapters.get(), pagination: "PAGE", continuous: "CONT", horizontal: "HORI" }; function getDisplayText() { return { ...DEFAULT_DISPLAY_TEXT, ...conf.displayText }; } function evLog(level, msg, ...info) { if (level === "debug" && !conf.debug) return; if (level === "error") { console.warn((/* @__PURE__ */ new Date()).toLocaleString(), "EHVP:" + msg, ...info); } else { console.info((/* @__PURE__ */ new Date()).toLocaleString(), "EHVP:" + msg, ...info); } } class EventManager { events; constructor() { this.events = /* @__PURE__ */ new Map(); } emit(id, ...args) { if (!["imf-download-state-change", "imf-check-picked"].includes(id)) { evLog("debug", "event bus emitted: ", id); } const cbs = this.events.get(id); let ret; if (cbs) { cbs.forEach((cb) => ret = cb(...args)); } return ret; } subscribe(id, cb) { evLog("info", "event bus subscribed: ", id); const cbs = this.events.get(id); if (cbs) { cbs.push(cb); } else { this.events.set(id, [cb]); } } reset() { this.events = /* @__PURE__ */ new Map(); } } const EBUS = new EventManager(); class Debouncer { tids; mode; lastExecTime; constructor(mode) { this.tids = {}; this.lastExecTime = Date.now(); this.mode = mode || "debounce"; } addEvent(id, event, timeout) { if (this.mode === "throttle") { const now = Date.now(); if (now - this.lastExecTime >= timeout) { this.lastExecTime = now; event(); } } else if (this.mode === "debounce") { window.clearTimeout(this.tids[id]); this.tids[id] = window.setTimeout(event, timeout); } } } function xhrWapper(url, respType, cb, headers, timeout) { if (_GM_xmlhttpRequest === void 0) throw new Error("your userscript manager does not support Gm_xmlhttpRequest api"); return _GM_xmlhttpRequest({ method: "GET", url, timeout: timeout || 6e5, responseType: respType, nocache: false, revalidate: false, headers: { "Referer": window.location.href, "Cache-Control": "public, max-age=2592000, immutable", ...headers }, ...cb })?.abort; } function simpleFetch(url, respType, headers) { return new Promise((resolve, reject) => { try { xhrWapper(url, respType, { onload: (response) => resolve(response.response), onerror: (error) => reject(error) }, headers ?? {}, 10 * 1e3); } catch (error) { reject(error); } }); } async function batchFetch(urls, concurrency, respType = "text") { const results = new Array(urls.length); let i = 0; while (i < urls.length) { const batch = urls.slice(i, i + concurrency); const batchPromises = batch.map( (url, index) => window.fetch(url).then((resp) => { if (resp.ok) { try { switch (respType) { case "text": return resp.text(); case "json": return resp.json(); case "arraybuffer": return resp.arrayBuffer(); } } catch (error) { throw new Error(`failed to fetch ${url}: ${resp.status} ${error}`); } } throw new Error(`failed to fetch ${url}: ${resp.status} ${resp.statusText}`); }).then((raw) => results[index + i] = raw).catch((reason) => results[index + i] = new Error(reason)) ); await Promise.all(batchPromises); i += concurrency; } return results; } var FetchState = /* @__PURE__ */ ((FetchState2) => { FetchState2[FetchState2["FAILED"] = 0] = "FAILED"; FetchState2[FetchState2["URL"] = 1] = "URL"; FetchState2[FetchState2["DATA"] = 2] = "DATA"; FetchState2[FetchState2["DONE"] = 3] = "DONE"; return FetchState2; })(FetchState || {}); class IMGFetcher { index; node; stage = 1 /* URL */; tryTimes = 0; lock = false; rendered = false; data; contentType; downloadState; timeoutId; matcher; chapterIndex; randomID; failedReason; constructor(index, root, matcher, chapterIndex) { this.index = index; this.node = root; this.node.onclick = (event) => { if (event.ctrlKey || event.metaKey) { EBUS.emit("add-cherry-pick-range", this.chapterIndex, this.index, true, event.shiftKey); } else if (event.altKey) { EBUS.emit("add-cherry-pick-range", this.chapterIndex, this.index, false, event.shiftKey); } else { EBUS.emit("imf-on-click", this); } }; this.downloadState = { total: 100, loaded: 0, readyState: 0 }; this.matcher = matcher; this.chapterIndex = chapterIndex; this.randomID = chapterIndex + Math.random().toString(16).slice(2) + this.node.href; } create() { const element = this.node.create(); const noEle = document.createElement("div"); noEle.classList.add("img-node-numtip"); noEle.innerHTML = `${this.index + 1}`; element.firstElementChild.appendChild(noEle); return element; } // 刷新下载状态 setDownloadState(newState) { this.downloadState = { ...this.downloadState, ...newState }; this.node.progress(this.downloadState); EBUS.emit("imf-download-state-change", this); } async start(index) { if (this.lock) return; this.lock = true; try { this.node.changeStyle("fetching"); await this.fetchImage(); this.node.changeStyle("fetched"); EBUS.emit("imf-on-finished", index, true, this); this.failedReason = void 0; } catch (error) { this.failedReason = error.toString(); this.node.changeStyle("failed", this.failedReason); evLog("error", `IMG-FETCHER ERROR:`, error); this.stage = 0 /* FAILED */; EBUS.emit("imf-on-finished", index, false, this); } finally { this.lock = false; } } resetStage() { this.node.changeStyle("init"); this.stage = 1 /* URL */; } async fetchImage() { const fetchMachine = async () => { try { switch (this.stage) { case 0 /* FAILED */: case 1 /* URL */: const meta = await this.fetchOriginMeta(); this.node.originSrc = meta.url; if (meta.title) { this.node.title = meta.title; if (this.node.imgElement) { this.node.imgElement.title = meta.title; } } this.node.href = meta.href || this.node.href; this.stage = 2 /* DATA */; return fetchMachine(); case 2 /* DATA */: const ret = await this.fetchImageData(); [this.data, this.contentType] = ret; [this.data, this.contentType] = await this.matcher.processData(this.data, this.contentType, this.node); if (this.contentType.startsWith("text")) { const str = new TextDecoder().decode(this.data); evLog("error", "unexpect content:\n", str); throw new Error(`expect image data, fetched wrong type: ${this.contentType}, the content is showing up in console(F12 open it).`); } this.node.blobSrc = transient.imgSrcCSP ? this.node.originSrc : URL.createObjectURL(new Blob([this.data], { type: this.contentType })); this.node.mimeType = this.contentType; this.node.render( (reason) => { evLog("error", "render image failed, " + reason); this.rendered = false; }, () => EBUS.emit("imf-resize", this) ); this.stage = 3 /* DONE */; case 3 /* DONE */: return null; } } catch (error) { this.stage = 0 /* FAILED */; return error; } }; this.tryTimes = 0; let err; while (this.tryTimes < 3) { err = await fetchMachine(); if (err === null) return; this.tryTimes++; evLog("error", `fetch image error, try times: ${this.tryTimes}, error:`, err); } throw err; } async fetchOriginMeta() { return await this.matcher.fetchOriginMeta(this.node, this.tryTimes > 0 || this.stage === 0 /* FAILED */, this.chapterIndex); } async fetchImageData() { const data = await this.fetchBigImage(); if (data == null) { throw new Error(`fetch image data is empty, image url:${this.node.originSrc}`); } return data.arrayBuffer().then((buffer) => [new Uint8Array(buffer), data.type]); } render() { const picked = EBUS.emit("imf-check-picked", this.chapterIndex, this.index) ?? this.node.picked; const shouldChangeStyle = picked !== this.node.picked; this.node.picked = picked; if (!this.rendered) { this.rendered = true; this.node.render( (reason) => { evLog("error", "render image failed, " + reason); this.rendered = false; }, () => EBUS.emit("imf-resize", this) ); this.node.changeStyle(this.stage === 3 /* DONE */ ? "fetched" : void 0, this.failedReason); } else if (shouldChangeStyle) { let status; switch (this.stage) { case 0 /* FAILED */: status = "failed"; break; case 1 /* URL */: status = "init"; break; case 2 /* DATA */: status = "fetching"; break; case 3 /* DONE */: status = "fetched"; break; } this.node.changeStyle(status, this.failedReason); } } isRender() { return this.rendered; } unrender() { if (!this.rendered) return; this.rendered = false; this.node.unrender(); this.node.changeStyle("init"); } ratio() { return this.node.ratio(); } async fetchBigImage() { if (this.node.originSrc?.startsWith("blob:")) { return await fetch(this.node.originSrc).then((resp) => resp.blob()); } const imgFetcher = this; return new Promise(async (resolve, reject) => { const debouncer = new Debouncer(); let abort = void 0; const timeout = () => { debouncer.addEvent("XHR_TIMEOUT", () => { reject(new Error("timeout")); abort?.(); }, conf.timeout * 1e3); }; try { abort = xhrWapper(imgFetcher.node.originSrc, "blob", { onload: function(response) { const data = response.response; try { imgFetcher.setDownloadState({ readyState: response.readyState }); } catch (error) { evLog("error", "warn: fetch big image data onload setDownloadState error:", error); } resolve(data); }, onerror: function(response) { if (response.status === 0 && response.error?.includes("URL is not permitted")) { const domain = response.error.match(/(https?:\/\/.*?)\/.*/)?.[1] ?? ""; reject(new Error(i18n.failFetchReason1.get().replace("{{domain}}", domain))); } else { reject(new Error(`response status:${response.status}, error:${response.error}, response:${response.response}`)); } }, onprogress: function(response) { imgFetcher.setDownloadState({ total: response.total, loaded: response.loaded, readyState: response.readyState }); timeout(); }, onloadstart: function() { imgFetcher.setDownloadState(imgFetcher.downloadState); } }, this.matcher.headers()); timeout(); } catch (error) { reject(error); } }); } } class Crc32 { crc = -1; table = this.makeTable(); makeTable() { let i; let j; let t; const table = []; for (i = 0; i < 256; i++) { t = i; for (j = 0; j < 8; j++) { t = t & 1 ? t >>> 1 ^ 3988292384 : t >>> 1; } table[i] = t; } return table; } append(data) { let crc = this.crc | 0; const table = this.table; for (let offset = 0, len = data.length | 0; offset < len; offset++) { crc = crc >>> 8 ^ table[(crc ^ data[offset]) & 255]; } this.crc = crc; } get() { return ~this.crc; } } class ZipObject { level; nameBuf; comment; header; offset; directory; file; crc; compressedLength; uncompressedLength; volumeNo; constructor(file, volumeNo) { this.level = 0; const encoder = new TextEncoder(); this.nameBuf = encoder.encode(file.name.trim()); this.comment = encoder.encode(""); this.header = new DataHelper(26); this.offset = 0; this.directory = false; this.file = file; this.crc = new Crc32(); this.compressedLength = 0; this.uncompressedLength = 0; this.volumeNo = volumeNo; } } class DataHelper { array; view; constructor(byteLength) { const uint8 = new Uint8Array(byteLength); this.array = uint8; this.view = new DataView(uint8.buffer); } } class Zip { // default 1.5GB volumeSize = 1610612736; accumulatedSize = 0; volumes = 1; currVolumeNo = -1; files = []; currIndex = -1; offset = 0; offsetInVolume = 0; curr; date; writer; close = false; constructor(settings) { if (settings?.volumeSize) { this.volumeSize = settings.volumeSize; } this.date = new Date(Date.now()); this.writer = async () => { }; } setWriter(writer) { this.writer = writer; } add(file) { const fileSize = file.size(); this.accumulatedSize += fileSize; if (this.accumulatedSize > this.volumeSize) { this.volumes++; this.accumulatedSize = fileSize; } this.files.push(new ZipObject(file, this.volumes - 1)); } async next() { this.currIndex++; this.curr = this.files[this.currIndex]; if (this.curr) { if (this.curr.volumeNo > this.currVolumeNo) { this.currIndex--; this.offsetInVolume = 0; return true; } this.curr.offset = this.offsetInVolume; await this.writeHeader(); await this.writeContent(); await this.writeFooter(); this.offset += this.offsetInVolume - this.curr.offset; } else if (!this.close) { this.close = true; await this.closeZip(); } else { return true; } return false; } async writeHeader() { if (!this.curr) return; const curr = this.curr; const data = new DataHelper(30 + curr.nameBuf.length); const header = curr.header; if (curr.level !== 0 && !curr.directory) { header.view.setUint16(4, 2048); } header.view.setUint32(0, 335546376); header.view.setUint16(6, (this.date.getHours() << 6 | this.date.getMinutes()) << 5 | this.date.getSeconds() / 2, true); header.view.setUint16(8, (this.date.getFullYear() - 1980 << 4 | this.date.getMonth() + 1) << 5 | this.date.getDate(), true); header.view.setUint16(22, curr.nameBuf.length, true); data.view.setUint32(0, 1347093252); data.array.set(header.array, 4); data.array.set(curr.nameBuf, 30); this.offsetInVolume += data.array.length; await this.writer(data.array); } async writeContent() { const curr = this.curr; const reader = (await curr.file.stream()).getReader(); const writer = this.writer; async function pump() { const chunk = await reader.read(); if (chunk.done) { return; } const data = chunk.value; curr.crc.append(data); curr.uncompressedLength += data.length; curr.compressedLength += data.length; writer(data); return await pump(); } await pump(); } async writeFooter() { if (!this.curr) return; const curr = this.curr; const footer = new DataHelper(16); footer.view.setUint32(0, 1347094280); if (curr.crc) { curr.header.view.setUint32(10, curr.crc.get(), true); curr.header.view.setUint32(14, curr.compressedLength, true); curr.header.view.setUint32(18, curr.uncompressedLength, true); footer.view.setUint32(4, curr.crc.get(), true); footer.view.setUint32(8, curr.compressedLength, true); footer.view.setUint32(12, curr.uncompressedLength, true); } await this.writer(footer.array); this.offsetInVolume += curr.compressedLength + 16; if (curr.compressedLength !== curr.file.size()) { evLog("error", "WRAN: read length:", curr.compressedLength, " origin size:", curr.file.size(), ", title: ", curr.file.name); } } async closeZip() { const fileCount = this.files.length; let centralDirLength = 0; let idx = 0; for (idx = 0; idx < fileCount; idx++) { const file = this.files[idx]; centralDirLength += 46 + file.nameBuf.length + file.comment.length; } const data = new DataHelper(centralDirLength + 22); let dataOffset = 0; for (idx = 0; idx < fileCount; idx++) { const file = this.files[idx]; data.view.setUint32(dataOffset, 1347092738); data.view.setUint16(dataOffset + 4, 5120); data.array.set(file.header.array, dataOffset + 6); data.view.setUint16(dataOffset + 32, file.comment.length, true); data.view.setUint16(dataOffset + 34, file.volumeNo, true); data.view.setUint32(dataOffset + 42, file.offset, true); data.array.set(file.nameBuf, dataOffset + 46); data.array.set(file.comment, dataOffset + 46 + file.nameBuf.length); dataOffset += 46 + file.nameBuf.length + file.comment.length; } data.view.setUint32(dataOffset, 1347093766); data.view.setUint16(dataOffset + 4, this.currVolumeNo, true); data.view.setUint16(dataOffset + 6, this.currVolumeNo, true); data.view.setUint16(dataOffset + 8, fileCount, true); data.view.setUint16(dataOffset + 10, fileCount, true); data.view.setUint32(dataOffset + 12, centralDirLength, true); data.view.setUint32(dataOffset + 16, this.offsetInVolume, true); await this.writer(data.array); } nextReadableStream() { this.currVolumeNo++; if (this.currVolumeNo >= this.volumes) { return; } const zip = this; return new ReadableStream({ start(controller) { zip.setWriter(async (chunk) => controller.enqueue(chunk)); }, async pull(controller) { await zip.next().then((done) => done && controller.close()); } }); } } class DownloaderCanvas { canvas; mousemoveState; ctx; queue; rectSize; rectGap; columns; padding; scrollTop; scrollSize; debouncer; onClick; cherryPick; constructor(canvas, queue, cherryPick) { this.queue = queue; this.cherryPick = cherryPick; if (!canvas) { throw new Error("canvas not found"); } this.canvas = canvas; this.canvas.addEventListener( "wheel", (event) => this.onwheel(event.deltaY) ); this.mousemoveState = { x: 0, y: 0 }; this.canvas.addEventListener("mousemove", (event) => { this.mousemoveState = { x: event.offsetX, y: event.offsetY }; this.drawDebouce(); }); this.canvas.addEventListener("click", (event) => { this.mousemoveState = { x: event.offsetX, y: event.offsetY }; const index = this.computeDrawList()?.find( (state) => state.selected )?.index; if (index !== void 0) { EBUS.emit("downloader-canvas-on-click", index); } }); this.ctx = this.canvas.getContext("2d"); this.rectSize = 12; this.rectGap = 6; this.columns = 15; this.padding = 7; this.scrollTop = 0; this.scrollSize = 10; this.debouncer = new Debouncer(); EBUS.subscribe("imf-download-state-change", () => this.drawDebouce()); EBUS.subscribe("downloader-canvas-resize", () => this.resize()); } resize(parent) { parent = parent || this.canvas.parentElement; this.canvas.width = Math.floor(parent.offsetWidth); this.canvas.height = Math.floor(parent.offsetHeight); this.columns = Math.ceil((this.canvas.width - this.padding * 2 - this.rectGap) / (this.rectSize + this.rectGap)); this.draw(); } onwheel(deltaY) { const [_, h] = this.getWH(); const clientHeight = this.computeClientHeight(); if (clientHeight > h) { deltaY = deltaY >> 1; this.scrollTop += deltaY; if (this.scrollTop < 0) this.scrollTop = 0; if (this.scrollTop + h > clientHeight + 20) this.scrollTop = clientHeight - h + 20; this.draw(); } } drawDebouce() { this.debouncer.addEvent("DOWNLOADER-DRAW", () => this.draw(), 20); } computeDrawList() { const list = []; const picked = this.cherryPick(); const [_, h] = this.getWH(); const startX = this.computeStartX(); const startY = -this.scrollTop + this.padding; for (let i = 0, row = -1; i < this.queue.length; i++) { const currCol = i % this.columns; if (currCol == 0) { row++; } const atX = startX + (this.rectSize + this.rectGap) * currCol; const atY = startY + (this.rectSize + this.rectGap) * row; if (atY + this.rectSize < 0) { continue; } if (atY > h) { break; } list.push({ index: i, x: atX, y: atY, selected: this.isSelected(atX, atY), disabled: !picked.picked(i) }); } return list; } // this function should be called by drawDebouce draw() { const [w, h] = this.getWH(); this.ctx.clearRect(0, 0, w, h); const drawList = this.computeDrawList(); for (const node of drawList) { this.drawSmallRect( node.x, node.y, this.queue[node.index], node.index === this.queue.currIndex, node.selected, node.disabled ); } } computeClientHeight() { return Math.ceil(this.queue.length / this.columns) * (this.rectSize + this.rectGap) - this.rectGap; } scrollTo(index) { const clientHeight = this.computeClientHeight(); const [_, h] = this.getWH(); if (clientHeight <= h) { return; } const rowNo = Math.ceil((index + 1) / this.columns); const offsetY = (rowNo - 1) * (this.rectSize + this.rectGap); if (offsetY > h) { this.scrollTop = offsetY + this.rectSize - h; const maxScrollTop = clientHeight - h + 20; if (this.scrollTop + 20 <= maxScrollTop) { this.scrollTop += 20; } } } isSelected(atX, atY) { return this.mousemoveState.x - atX >= 0 && this.mousemoveState.x - atX <= this.rectSize && this.mousemoveState.y - atY >= 0 && this.mousemoveState.y - atY <= this.rectSize; } computeStartX() { const [w, _] = this.getWH(); const drawW = (this.rectSize + this.rectGap) * this.columns - this.rectGap; const startX = w - drawW >> 1; return startX; } drawSmallRect(x, y, imgFetcher, isCurr, isSelected, disabled) { if (disabled) { this.ctx.fillStyle = "rgba(20, 20, 20, 1)"; } else { switch (imgFetcher.stage) { case FetchState.FAILED: this.ctx.fillStyle = "rgba(250, 50, 20, 0.9)"; break; case FetchState.URL: this.ctx.fillStyle = "rgba(200, 200, 200, 0.6)"; break; case FetchState.DATA: const percent = imgFetcher.downloadState.loaded / imgFetcher.downloadState.total; this.ctx.fillStyle = `rgba(${200 + Math.ceil((110 - 200) * percent)}, ${200 + Math.ceil((200 - 200) * percent)}, ${200 + Math.ceil((120 - 200) * percent)}, ${0.6 + Math.ceil((1 - 0.6) * percent)})`; break; case FetchState.DONE: this.ctx.fillStyle = "rgb(110, 200, 120)"; break; } } this.ctx.fillRect(x, y, this.rectSize, this.rectSize); this.ctx.shadowColor = "#d53"; if (isSelected) { this.ctx.strokeStyle = "rgb(60, 20, 200)"; this.ctx.lineWidth = 2; } else if (isCurr) { this.ctx.strokeStyle = "rgb(255, 60, 20)"; this.ctx.lineWidth = 2; } else { this.ctx.strokeStyle = "rgb(90, 90, 90)"; this.ctx.lineWidth = 1; } this.ctx.strokeRect(x, y, this.rectSize, this.rectSize); } getWH() { return [this.canvas.width, this.canvas.height]; } } const FILENAME_INVALIDCHAR = /[\\/:*?"<>|\n\t]/g; class Downloader { meta; title; downloading; queue; idleLoader; pageFetcher; done = false; selectedChapters = []; filenames = /* @__PURE__ */ new Set(); panel; canvas; cherryPicks = [new CherryPick()]; constructor(HTML, queue, idleLoader, pageFetcher, matcher) { this.panel = HTML.downloader; this.panel.initTabs(); this.initEvents(this.panel); this.panel.initCherryPick( (chapterIndex, range) => { if (this.cherryPicks[chapterIndex] === void 0) { this.cherryPicks[chapterIndex] = new CherryPick(); } const ret = this.cherryPicks[chapterIndex].add(range); EBUS.emit("cherry-pick-changed", chapterIndex, this.cherryPicks[chapterIndex]); return ret; }, (chapterIndex, id) => { if (this.cherryPicks[chapterIndex] === void 0) { this.cherryPicks[chapterIndex] = new CherryPick(); } const ret = this.cherryPicks[chapterIndex].remove(id); EBUS.emit("cherry-pick-changed", chapterIndex, this.cherryPicks[chapterIndex]); return ret; }, (chapterIndex) => { if (this.cherryPicks[chapterIndex] === void 0) { this.cherryPicks[chapterIndex] = new CherryPick(); } this.cherryPicks[chapterIndex].reset(); EBUS.emit("cherry-pick-changed", chapterIndex, this.cherryPicks[chapterIndex]); }, (chapterIndex) => { if (this.cherryPicks[chapterIndex] === void 0) { this.cherryPicks[chapterIndex] = new CherryPick(); } return this.cherryPicks[chapterIndex].values; } ); this.panel.initNotice([ { btn: i18n.resetDownloaded.get(), cb: () => { if (confirm(i18n.resetDownloadedConfirm.get())) this.queue.forEach((imf) => imf.stage === FetchState.DONE && imf.resetStage()); } }, { btn: i18n.resetFailed.get(), cb: () => { this.queue.forEach((imf) => imf.stage === FetchState.FAILED && imf.resetStage()); if (!this.downloading) this.idleLoader.abort(0, 100); } } ]); this.queue = queue; this.queue.cherryPick = () => this.cherryPicks[this.queue.chapterIndex] || new CherryPick(); this.idleLoader = idleLoader; this.idleLoader.cherryPick = () => this.cherryPicks[this.queue.chapterIndex] || new CherryPick(); this.canvas = new DownloaderCanvas(this.panel.canvas, queue, () => this.cherryPicks[this.queue.chapterIndex] || new CherryPick()); this.pageFetcher = pageFetcher; this.meta = (chapter) => matcher.galleryMeta(chapter); this.title = (chapters) => matcher.title(chapters); this.downloading = false; this.queue.downloading = () => this.downloading; EBUS.subscribe("ifq-on-finished-report", (_, queue2) => { if (queue2.isFinished()) { const sel = this.selectedChapters.find((sel2) => sel2.index === queue2.chapterIndex); if (sel) { sel.done = true; sel.resolve(true); } if (!this.downloading && !this.done) { this.panel.noticeableBTN(); } } }); EBUS.subscribe("imf-check-picked", (chapterIndex, index) => this.cherryPicks[chapterIndex]?.picked(index)); } initEvents(panel) { panel.forceBTN.addEventListener("click", () => this.download(this.pageFetcher.chapters)); panel.startBTN.addEventListener("click", () => { if (this.downloading) { this.abort("downloadStart"); } else { this.start(); } }); } needNumberTitle(queue) { if (conf.filenameOrder === "numbers") return true; if (conf.filenameOrder === "original") return false; let comparer; if (conf.filenameOrder === "alphabetically") { comparer = (a, before) => a < before; } else { comparer = (a, before) => a.localeCompare(before, void 0, { numeric: true, sensitivity: "base" }) < 0; } let lastTitle = ""; for (const fetcher of queue) { if (lastTitle && comparer(fetcher.node.title, lastTitle)) { return true; } lastTitle = fetcher.node.title; } return false; } // check > start > download check() { if (this.downloading) return; setTimeout(() => EBUS.emit("downloader-canvas-resize"), 110); this.panel.createChapterSelectList(this.pageFetcher.chapters, this.selectedChapters); if (this.queue.length > 0) { this.panel.switchTab("status"); } else if (this.pageFetcher.chapters.length > 1) { this.panel.switchTab("chapters"); } } checkSelectedChapters() { this.selectedChapters.length = 0; const idSet = this.panel.selectedChapters(); if (idSet.size === 0) { this.selectedChapters.push({ index: 0, done: false, ...promiseWithResolveAndReject() }); } else { this.pageFetcher.chapters.forEach((c, i) => idSet.has(c.id) && this.selectedChapters.push({ index: i, done: false, ...promiseWithResolveAndReject() })); } return this.selectedChapters; } async start() { if (this.downloading) return; this.panel.flushUI("downloading"); this.downloading = true; this.idleLoader.autoLoad = true; this.checkSelectedChapters(); try { for (const sel of this.selectedChapters) { if (!this.downloading) return; await this.pageFetcher.changeChapter(sel.index); this.queue.forEach((imf) => imf.stage === FetchState.FAILED && imf.resetStage()); if (this.queue.isFinished()) { sel.done = true; sel.resolve(true); } else { this.idleLoader.processingIndexList = this.queue.map((imgFetcher, index) => !imgFetcher.lock && imgFetcher.stage === FetchState.URL ? index : -1).filter((index) => index >= 0).splice(0, conf.downloadThreads); this.idleLoader.onFailed(() => sel.reject("download failed or canceled")); this.idleLoader.checkProcessingIndex(); this.idleLoader.start(); } await sel.promise; } if (this.downloading) await this.download(this.selectedChapters.filter((sel) => sel.done).map((sel) => this.pageFetcher.chapters[sel.index])); } catch (error) { if ("abort" === error) return; this.abort("downloadFailed"); evLog("error", "download failed: ", error); } finally { this.downloading = false; } } mapToFileLikes(chapter, picked, directory) { if (!chapter || chapter.queue.length === 0) return []; let checkTitle; const needNumberTitle = this.needNumberTitle(chapter.queue); if (needNumberTitle) { const digits = chapter.queue.length.toString().length; if (conf.filenameOrder === "numbers") { checkTitle = (title, index) => `${index + 1}`.padStart(digits, "0") + "." + title.split(".").pop(); } else { checkTitle = (title, index) => `${index + 1}`.padStart(digits, "0") + "_" + title.replaceAll(FILENAME_INVALIDCHAR, "_"); } } else { this.filenames.clear(); checkTitle = (title) => deduplicate(this.filenames, title.replaceAll(FILENAME_INVALIDCHAR, "_")); } const ret = chapter.queue.filter((imf, i) => picked.picked(i) && imf.stage === FetchState.DONE && imf.data).map((imf, index) => { return { stream: () => Promise.resolve(uint8ArrayToReadableStream(imf.data)), size: () => imf.data.byteLength, name: directory + checkTitle(imf.node.title, index) }; }); const meta = new TextEncoder().encode(JSON.stringify(this.meta(chapter), null, 2)); ret.push({ stream: () => Promise.resolve(uint8ArrayToReadableStream(meta)), size: () => meta.byteLength, name: directory + "meta.json" }); return ret; } async download(chapters) { try { const archiveName = this.title(chapters).replaceAll(FILENAME_INVALIDCHAR, "_"); const separator = navigator.userAgent.indexOf("Win") !== -1 ? "\\" : "/"; const singleChapter = chapters.length === 1; this.panel.flushUI("packaging"); const dirnameSet = /* @__PURE__ */ new Set(); const files = []; for (let i = 0; i < chapters.length; i++) { const chapter = chapters[i]; const picked = this.cherryPicks[i] || new CherryPick(); let directory = (() => { if (singleChapter) return ""; if (chapter.title instanceof Array) { return chapter.title.join("_").replaceAll(FILENAME_INVALIDCHAR, "_").replaceAll(/\s+/g, " ") + separator; } else { return chapter.title.replaceAll(FILENAME_INVALIDCHAR, "_").replaceAll(/\s+/g, " ") + separator; } })(); directory = shrinkFilename(directory, 200); directory = deduplicate(dirnameSet, directory); const ret = this.mapToFileLikes(chapter, picked, directory); files.push(...ret); } const zip = new Zip({ volumeSize: 1024 * 1024 * (conf.archiveVolumeSize || 1500) }); files.forEach((file) => zip.add(file)); const save = async () => { let readable; while (readable = zip.nextReadableStream()) { const blob = await new Response(readable).blob(); const ext = zip.currVolumeNo === zip.volumes - 1 ? "zip" : "z" + (zip.currVolumeNo + 1).toString().padStart(2, "0"); fileSaver.saveAs(blob, `${archiveName}.${ext}`); } }; await save(); this.done = true; } catch (error) { let reason = error.toString(); if (reason.includes(`autoAllocateChunkSize`)) { reason = "Create Zip archive prevented by The content security policy of this page. Please refer to the CONF > Help for a solution."; } EBUS.emit("notify-message", "error", `packaging failed, ${reason}`); throw error; } finally { this.abort(this.done ? "downloaded" : "downloadFailed"); } } abort(stage) { this.downloading = false; this.panel.abort(stage); this.idleLoader.abort(); this.selectedChapters.forEach((sel) => sel.reject("abort")); } } function shrinkFilename(str, limit) { const encoder = new TextEncoder(); const byteLen = (s) => encoder.encode(s).byteLength; const bLen = byteLen(str); if (bLen <= limit) return str; const sliceRange = [str.length >> 1, (str.length >> 1) + 1]; let left = true; while (true) { if (bLen - byteLen(str.slice(...sliceRange)) <= limit) { return str.slice(0, sliceRange[0]) + ",,," + str.slice(sliceRange[1]); } if (left && sliceRange[0] > 3) { sliceRange[0] -= 1; left = false; continue; } if (sliceRange[1] < str.length - 3) { sliceRange[1] += 1; left = true; continue; } break; } return str.slice(0, limit); } function deduplicate(set, title) { let newTitle = title; if (set.has(newTitle)) { const splits = newTitle.split("."); const ext = splits.pop(); const prefix = splits.join("."); const num = parseInt(prefix.match(/_(\d+)$/)?.[1] || ""); if (isNaN(num)) { newTitle = `${prefix}_1.${ext}`; } else { newTitle = `${prefix.replace(/\d+$/, (num + 1).toString())}.${ext}`; } return deduplicate(set, newTitle); } else { set.add(newTitle); return newTitle; } } function uint8ArrayToReadableStream(arr) { return new ReadableStream({ pull(controller) { controller.enqueue(arr); controller.close(); } }); } function promiseWithResolveAndReject() { let resolve; let reject; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { resolve, reject, promise }; } class CherryPick { values = []; positive = false; // if values has positive picked, ignore exclude sieve = []; reset() { this.values = []; this.positive = false; this.sieve = []; } add(range) { if (this.values.length === 0) { this.positive = range.positive; this.values.push(range); this.setSieve(range); return this.values; } const exists = this.values.find((v) => v.id === range.id); if (exists) return null; const newR = range.range(); const remIdSet = /* @__PURE__ */ new Set(); const addIdSet = /* @__PURE__ */ new Set(); const addList = []; let equalsOld = false; for (let i = 0; i < this.values.length; i++) { const old = this.values[i]; const oldR = old.range(); if (newR[0] >= oldR[0] && newR[1] <= oldR[1]) { if (range.positive !== this.positive) { remIdSet.add(old.id); if (oldR[0] < newR[0]) { addList.push(new CherryPickRange([oldR[0], newR[0] - 1], old.positive)); } if (oldR[1] > newR[1]) { addList.push(new CherryPickRange([newR[1] + 1, oldR[1]], old.positive)); } equalsOld = newR[0] === newR[1] && newR[0] === oldR[0] && newR[1] === oldR[1]; } break; } if (newR[0] <= oldR[0] && newR[1] >= oldR[1]) { remIdSet.add(old.id); } else if (newR[0] <= oldR[0] && newR[1] >= oldR[0] && newR[1] <= oldR[1]) { old.reset([newR[1] + 1, oldR[1]]); } else if (newR[0] >= oldR[0] && newR[0] <= oldR[1] && newR[1] >= oldR[1]) { old.reset([oldR[0], newR[0] - 1]); } if (range.positive === this.positive) { if (!addIdSet.has(range.id)) { addIdSet.add(range.id); addList.push(range); } } } if (remIdSet.size > 0) { this.values = this.values.filter((v) => !remIdSet.has(v.id)); } if (addList.length > 0) { this.values.push(...addList); } if (this.values.length === 0) { this.reset(); if (equalsOld) { return this.values; } this.positive = range.positive; this.values.push(range); } else { this.concat(); } this.setSieve(range); return this.values; } setSieve(range) { const newR = range.range(); for (let i = newR[0] - 1; i < newR[1]; i++) { this.sieve[i] = range.positive === this.positive; } } concat() { if (this.values.length < 2) return; this.values.sort((v1, v2) => v1.range()[0] - v2.range()[0]); let i = 0, j = 1; const skip = []; while (i < this.values.length && j < this.values.length) { const r1 = this.values[i]; const r2 = this.values[j]; const r1v = r1.range(); const r2v = r2.range(); if (r1v[1] + 1 === r2v[0]) { r1.reset([r1v[0], r2v[1]]); skip.push(j); j++; } else { do { i++; } while (skip.includes(i)); j = i + 1; } } this.values = this.values.filter((_, i2) => !skip.includes(i2)); } remove(id) { const index = this.values.findIndex((v) => v.id === id); if (index === -1) return; const range = this.values.splice(index, 1)[0]; const r = range.range(); for (let i = r[0] - 1; i < r[1]; i++) { this.sieve[i] = false; } if (this.values.length === 0) { this.sieve = []; this.positive = false; } } picked(index) { return Boolean(this.positive ? this.sieve[index] : !this.sieve[index]); } } class CherryPickRange { value; positive; id; constructor(value, positive) { this.positive = positive; this.value = value.sort((a, b) => a - b); this.id = CherryPickRange.rangeToString(this.value, this.positive); } toString() { return CherryPickRange.rangeToString(this.value, this.positive); } reset(newRange) { this.value = newRange.sort((a, b) => a - b); this.id = CherryPickRange.rangeToString(this.value, this.positive); } range() { return this.value; } static rangeToString(value, positive) { let str = ""; if (value[0] === value[1]) { str = value[0].toString(); } else { str = value.map((v) => v.toString()).join("-"); } return positive ? str : "!" + str; } static from(value) { value = value?.trim(); if (!value) return null; value = value.replace(/!+/, "!"); const exclude = value.startsWith("!"); if (/^!?\d+$/.test(value)) { const index = parseInt(value.replace("!", "")); return new CherryPickRange([index, index], !exclude); } if (/^!?\d+-\d+$/.test(value)) { const splits = value.replace("!", "").split("-").map((v) => parseInt(v)); return new CherryPickRange([splits[0], splits[1]], !exclude); } return null; } } class IMGFetcherQueue extends Array { executableQueue; currIndex; finishedIndex = /* @__PURE__ */ new Set(); debouncer; downloading; dataSize = 0; chapterIndex = 0; cherryPick; clear() { this.length = 0; this.executableQueue = []; this.currIndex = 0; this.finishedIndex.clear(); } restore(chapterIndex, imfs) { this.clear(); this.chapterIndex = chapterIndex; imfs.forEach((imf, i) => imf.stage === FetchState.DONE && this.finishedIndex.add(i)); this.push(...imfs); } static newQueue() { const queue = new IMGFetcherQueue(); EBUS.subscribe("imf-on-finished", (index, success, imf) => queue.chapterIndex === imf.chapterIndex && queue.finishedReport(index, success, imf)); EBUS.subscribe("ifq-do", (index, imf, oriented) => { if (imf.chapterIndex !== queue.chapterIndex) return; queue.do(index, oriented); }); EBUS.subscribe("pf-change-chapter", () => queue.forEach((imf) => imf.unrender())); return queue; } constructor() { super(); this.executableQueue = []; this.currIndex = 0; this.debouncer = new Debouncer(); } isFinished() { const picked = this.cherryPick?.(this.chapterIndex); if (picked && picked.values.length > 0) { for (let index = 0; index < this.length; index++) { if (picked.picked(index) && !this.finishedIndex.has(index)) { return false; } } return true; } else { return this.finishedIndex.size === this.length; } } do(start, oriented) { oriented = oriented || "next"; this.currIndex = this.fixIndex(start); EBUS.emit("ifq-on-do", this.currIndex, this, this.downloading?.() || false); if (this.downloading?.()) return; if (!this.pushInExecutableQueue(oriented)) return; this.debouncer.addEvent("IFQ-EXECUTABLE", () => { console.log("IFQ-EXECUTABLE: ", this.executableQueue); Promise.all(this.executableQueue.splice(0, conf.paginationIMGCount).map((imfIndex) => this[imfIndex].start(imfIndex))).then(() => { const picked = this.cherryPick?.(this.chapterIndex); this.executableQueue.filter((i) => !picked || picked.picked(i)).forEach((imfIndex) => this[imfIndex].start(imfIndex)); }); }, 300); } //等待图片获取器执行成功后的上报,如果该图片获取器上报自身所在的索引和执行队列的currIndex一致,则改变大图 finishedReport(index, success, imf) { if (this.length === 0) return; if (!success || imf.stage !== FetchState.DONE) return; this.finishedIndex.add(index); if (this.dataSize < 1e9) { this.dataSize += imf.data?.byteLength || 0; } EBUS.emit("ifq-on-finished-report", index, this); } //如果开始的索引小于0,则修正索引为0,如果开始的索引超过队列的长度,则修正索引为队列的最后一位 fixIndex(start) { return start < 0 ? 0 : start > this.length - 1 ? this.length - 1 : start; } /** * 将方向前|后 的未加载大图数据的图片获取器放入待加载队列中 * 从当前索引开始,向后或向前进行遍历, * 会跳过已经加载完毕的图片获取器, * 会添加正在获取大图数据或未获取大图数据的图片获取器到待加载队列中 * @param oriented 方向 前后 * @returns 是否添加成功 */ pushInExecutableQueue(oriented) { this.executableQueue = []; for (let count = 0, index = this.currIndex; this.checkOutbounds(index, oriented, count); oriented === "next" ? ++index : --index) { if (this[index].stage === FetchState.DONE) continue; this.executableQueue.push(index); count++; } return this.executableQueue.length > 0; } // 如果索引已到达边界且添加数量在配置最大同时获取数量的范围内 checkOutbounds(index, oriented, count) { let ret = false; if (oriented === "next") ret = index < this.length; if (oriented === "prev") ret = index > -1; if (!ret) return false; if (count < conf.threads + conf.paginationIMGCount - 1) return true; return false; } findImgIndex(ele) { for (let index = 0; index < this.length; index++) { if (this[index].node.equal(ele)) { return index; } } return 0; } } class IdleLoader { queue; processingIndexList; restartId; maxWaitMS; minWaitMS; onFailedCallback; autoLoad = false; debouncer; cherryPick; constructor(queue) { this.queue = queue; this.processingIndexList = [0]; this.maxWaitMS = 1e3; this.minWaitMS = 300; this.autoLoad = conf.autoLoad; this.debouncer = new Debouncer(); EBUS.subscribe("ifq-on-do", (currIndex, _, downloading) => !downloading && this.abort(currIndex)); EBUS.subscribe("imf-on-finished", (index) => { if (!this.processingIndexList.includes(index)) return; this.wait().then(() => { this.checkProcessingIndex(); this.start(); }); }); EBUS.subscribe("pf-change-chapter", (index) => !this.queue.downloading?.() && this.abort(index > 0 ? 0 : void 0)); window.addEventListener("focus", () => { if (conf.autoLoadInBackground) return; this.debouncer.addEvent("Idle-Load-on-focus", () => { console.log("[ IdleLoader ] window focus, document.hidden:", document.hidden); if (document.hidden) return; this.abort(0, 10); }, 100); }); EBUS.subscribe("pf-on-appended", (_total, _nodes, _chapterIndex, done) => { if (done || this.processingIndexList.length > 0) return; this.abort(this.queue.currIndex, 100); }); } onFailed(cb) { this.onFailedCallback = cb; } start() { if (!this.autoLoad) return; if (document.hidden && !conf.autoLoadInBackground) return; if (this.processingIndexList.length === 0) return; if (this.queue.length === 0) return; evLog("info", "Idle Loader start at:" + this.processingIndexList.toString()); for (const processingIndex of this.processingIndexList) { this.queue[processingIndex].start(processingIndex); } } checkProcessingIndex() { if (this.queue.length === 0) { return; } const picked = this.cherryPick?.() || new CherryPick(); const foundFetcherIndex = /* @__PURE__ */ new Set(); let hasFailed = false; for (let i = 0; i < this.processingIndexList.length; i++) { const processingIndex = this.processingIndexList[i]; const imf = this.queue[processingIndex]; if (imf.stage === FetchState.FAILED) { hasFailed = true; } if (imf.lock || imf.stage === FetchState.URL) { continue; } for (let j = Math.min(processingIndex + 1, this.queue.length - 1), limit = this.queue.length; j < limit; j++) { if (picked.picked(j)) { const imf2 = this.queue[j]; if (!imf2.lock && imf2.stage === FetchState.URL && !foundFetcherIndex.has(j)) { foundFetcherIndex.add(j); this.processingIndexList[i] = j; break; } if (imf2.stage === FetchState.FAILED) { hasFailed = true; } } if (j >= this.queue.length - 1) { limit = processingIndex; j = 0; } } if (foundFetcherIndex.size === 0) { this.processingIndexList.length = 0; if (hasFailed && this.onFailedCallback) { this.onFailedCallback(); this.onFailedCallback = void 0; } return; } } } async wait() { const { maxWaitMS, minWaitMS } = this; return new Promise(function(resolve) { const time = Math.floor(Math.random() * maxWaitMS + minWaitMS); window.setTimeout(() => resolve(true), time); }); } abort(newIndex, delayRestart) { this.processingIndexList = []; this.debouncer.addEvent("IDLE-LOAD-ABORT", () => { if (!this.autoLoad) return; if (newIndex === void 0) return; if (this.queue.downloading?.()) return; this.processingIndexList = [newIndex]; this.checkProcessingIndex(); this.start(); }, delayRestart || conf.restartIdleLoader); } } class PageFetcher { chapters = []; chapterIndex = 0; queue; matcher; beforeInit; afterInit; appendPageLock = false; abortb = false; constructor(queue, matcher) { this.queue = queue; this.matcher = matcher; const debouncer = new Debouncer(); EBUS.subscribe("ifq-on-finished-report", (index) => debouncer.addEvent("APPEND-NEXT-PAGES", () => this.appendPages(index), 5)); EBUS.subscribe("imf-on-finished", (index, success, imf) => { if (index === 0 && success) { this.chapters[imf.chapterIndex].thumbimg = imf.node.blobSrc; } }); EBUS.subscribe("pf-try-extend", () => debouncer.addEvent("APPEND-NEXT-PAGES", () => !this.queue.downloading?.() && this.appendNextPage(), 5)); EBUS.subscribe("pf-retry-extend", () => !this.queue.downloading?.() && this.appendNextPage(true)); EBUS.subscribe("pf-init", (cb) => this.init().then(cb)); EBUS.subscribe("pf-append-chapters", (url) => this.appendNewChapters(url).then(() => this.chapters)); } appendToView(total, nodes, chapterIndex, done) { EBUS.emit("pf-on-appended", total, nodes, chapterIndex, done); } abort() { this.abortb = true; } async appendNewChapters(url) { try { const chapters = await this.matcher.appendNewChapters(url, this.chapters); if (chapters && chapters.length > 0) { chapters.forEach((c) => { c.sourceIter = this.matcher.fetchPagesSource(c); c.onclick = (index) => { EBUS.emit("pf-change-chapter", index, c); if (this.chapters[index].queue.length > 0) { this.appendToView(this.chapters[index].queue.length, this.chapters[index].queue, index, this.chapters[index].done); } if (!this.queue.downloading?.()) { this.beforeInit?.(); this.changeChapter(index).then(this.afterInit).catch(this.onFailed); } }; }); this.chapters.push(...chapters); EBUS.emit("pf-update-chapters", this.chapters, true); } } catch (error) { EBUS.emit("notify-message", "error", `${error}`); } } async init() { this.beforeInit?.(); this.chapters = await this.matcher.fetchChapters().catch((reason) => EBUS.emit("notify-message", "error", reason) || []); this.afterInit?.(); this.chapters.forEach((c) => { c.sourceIter = this.matcher.fetchPagesSource(c); c.onclick = (index) => { EBUS.emit("pf-change-chapter", index, c); if (this.chapters[index].queue.length > 0) { this.appendToView(this.chapters[index].queue.length, this.chapters[index].queue, index, this.chapters[index].done); } if (!this.queue.downloading?.()) { this.beforeInit?.(); this.changeChapter(index).then(this.afterInit).catch(this.onFailed); } }; }); EBUS.emit("pf-update-chapters", this.chapters); if (this.chapters.length === 1) { this.beforeInit?.(); EBUS.emit("pf-change-chapter", 0, this.chapters[0]); await this.changeChapter(0).then(this.afterInit).catch(this.onFailed); } } /// start the chapter by index async changeChapter(index) { this.chapterIndex = index; const chapter = this.chapters[this.chapterIndex]; this.queue.restore(index, chapter.queue); if (!chapter.sourceIter) { evLog("error", "chapter sourceIter is not set!"); return; } if (chapter.queue.length === 0) { const first = await chapter.sourceIter.next(); if (!first.done) { if (first.value.error) throw first.value.error; await this.appendImages(first.value.value); } this.appendPages(this.queue.length); } } // append next page until the queue length is 60 more than finished async appendPages(appendedCount) { while (true) { if (appendedCount + 60 < this.queue.length) break; if (!await this.appendNextPage()) break; } } async appendNextPage(force) { if (this.appendPageLock) return false; try { this.appendPageLock = true; const chapter = this.chapters[this.chapterIndex]; if (force) chapter.done = false; if (chapter.done || this.abortb) return false; const next = await chapter.sourceIter.next(); if (next.done) { chapter.done = true; this.appendToView(this.queue.length, [], this.chapterIndex, true); return false; } else { if (next.value.error) { chapter.done = true; throw next.value.error; } return await this.appendImages(next.value.value); } } catch (error) { evLog("error", "PageFetcher:appendNextPage error: ", error); this.onFailed?.(error); return false; } finally { this.appendPageLock = false; } } async appendImages(pageSource) { try { const nodes = await this.obtainImageNodeList(pageSource); if (this.abortb) return false; if (nodes.length === 0) return false; const len = this.queue.length; const IFs = nodes.map( (imgNode, index) => new IMGFetcher(index + len, imgNode, this.matcher, this.chapterIndex) ); this.queue.push(...IFs); this.chapters[this.chapterIndex].queue.push(...IFs); this.appendToView(this.queue.length, IFs, this.chapterIndex); return true; } catch (error) { evLog("error", `page fetcher append images error: `, error); this.onFailed?.(error); return false; } } //从文档的字符串中创建缩略图元素列表 async obtainImageNodeList(pageSource) { let tryTimes = 0; let err; while (tryTimes < 3) { try { return await this.matcher.parseImgNodes(pageSource, this.chapters[this.chapterIndex].id); } catch (error) { evLog("error", "warn: parse image nodes failed, retrying: ", error); tryTimes++; err = error; } } evLog("error", "warn: parse image nodes failed: reached max try times!"); throw err; } //通过地址请求该页的文档 async fetchDocument(pageURL) { return await window.fetch(pageURL).then((response) => response.text()); } onFailed(reason) { EBUS.emit("notify-message", "error", reason.toString()); } } class GalleryMeta { url; title; originTitle; downloader; tags; constructor(url, title) { this.url = url; this.title = title; this.tags = {}; this.downloader = "https://github.com/MapoMagpie/eh-view-enhance"; } } const PICA = new pica({ features: ["wasm"] }); const PICA_OPTION = { filter: "box" }; async function resizing(from, to) { return PICA.resize(from, to, PICA_OPTION).then(); } const DEFAULT_THUMBNAIL = "data:image/gif;base64,R0lGODlhAQABAIAAAMLCwgAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="; const DEFAULT_NODE_TEMPLATE = document.createElement("div"); DEFAULT_NODE_TEMPLATE.classList.add("img-node"); DEFAULT_NODE_TEMPLATE.innerHTML = ` `; const OVERLAY_TIP = document.createElement("div"); OVERLAY_TIP.classList.add("overlay-tip"); OVERLAY_TIP.innerHTML = `GIF`; class ImageNode { root; thumbnailSrc; href; title; onclick; imgElement; canvasElement; canvasCtx; delaySRC; originSrc; blobSrc; mimeType; downloadBar; picked = true; debouncer = new Debouncer(); rect; tags; constructor(thumbnailSrc, href, title, delaySRC, originSrc, wh) { this.thumbnailSrc = thumbnailSrc; this.href = href; this.title = title; this.delaySRC = delaySRC; this.originSrc = originSrc; this.rect = wh; this.tags = /* @__PURE__ */ new Set(); } setTags(...tags) { tags.forEach(this.tags.add); } hasTag(tag) { return this.tags.has(tag); } create() { this.root = DEFAULT_NODE_TEMPLATE.cloneNode(true); const anchor = this.root.firstElementChild; anchor.href = this.href; anchor.target = "_blank"; this.imgElement = anchor.firstElementChild; this.canvasElement = anchor.lastElementChild; this.imgElement.setAttribute("title", this.title); this.canvasElement.id = "canvas-" + this.title.replaceAll(/[^\w]/g, "_"); const ratio = this.ratio(); this.root.style.aspectRatio = ratio.toString(); this.root.setAttribute("data-ratio", ratio.toString()); this.canvasElement.width = 512; this.canvasElement.height = Math.floor(512 / ratio); this.canvasCtx = this.canvasElement.getContext("2d"); this.canvasCtx.fillStyle = "#aaa"; this.canvasCtx.fillRect(0, 0, this.canvasElement.width, this.canvasElement.height); if (this.onclick) { anchor.addEventListener("click", (event) => { event.preventDefault(); this.onclick(event); }); } return this.root; } resize(onfailed, onResize) { if (!this.root || !this.imgElement || !this.canvasElement) return onfailed("undefined elements"); if (!this.imgElement.src || this.imgElement.src === DEFAULT_THUMBNAIL) return onfailed("empty or default src"); if (this.root.offsetWidth <= 1) return onfailed("element too small"); if (this.imgElement.src === this.imgElement.getAttribute("data-rendered")) return; this.imgElement.onload = null; this.imgElement.onerror = null; const oldRatio = this.ratio(); this.rect = { w: this.imgElement.naturalWidth, h: this.imgElement.naturalHeight }; const newRatio = this.ratio(); const flowVision = this.root.parentElement?.classList.contains("fvg-sub-container"); if (Math.abs(newRatio - oldRatio) > 0.07) { this.root.style.aspectRatio = newRatio.toString(); this.root.setAttribute("data-ratio", newRatio.toString()); if (flowVision) { this.canvasElement.height = this.root.offsetHeight; this.canvasElement.width = Math.floor(this.root.offsetHeight / newRatio); } else { this.canvasElement.width = this.root.offsetWidth; this.canvasElement.height = Math.floor(this.root.offsetWidth * newRatio); } onResize(); } const resized = (src) => { this.imgElement.src = ""; this.imgElement.setAttribute("data-rendered", src); }; if (this.imgElement.src === this.thumbnailSrc || newRatio < 0.1) { this.canvasCtx?.drawImage(this.imgElement, 0, 0, this.canvasElement.width, this.canvasElement.height); resized(this.imgElement.src); } else { resizing(this.imgElement, this.canvasElement).then(() => window.setTimeout(() => resized(this.imgElement.src), 100)).catch(() => resized(this.canvasCtx?.drawImage(this.imgElement, 0, 0, this.canvasElement.width, this.canvasElement.height) || "")); } } ratio() { if (this.rect) { return Math.floor(this.rect.w / this.rect.h * 1e3) / 1e3; } return 1; } render(onfailed, onResize) { this.debouncer.addEvent("IMG-RENDER", () => { if (!this.imgElement) return onfailed("element undefined"); let justThumbnail = !conf.hdThumbnails || !this.blobSrc; if (this.mimeType === "image/gif" || this.mimeType?.startsWith("video")) { const tip = OVERLAY_TIP.cloneNode(true); tip.firstChild.textContent = this.mimeType.split("/")[1].toUpperCase(); this.root?.appendChild(tip); justThumbnail = true; } this.imgElement.onload = () => this.resize(onfailed, onResize); this.imgElement.onerror = () => onfailed("img load error"); if (justThumbnail) { const delaySRC = this.delaySRC; this.delaySRC = void 0; if (delaySRC) { delaySRC.then((src) => (this.thumbnailSrc = src) && this.render(onfailed, onResize)).catch(onfailed); } else { this.imgElement.src = this.thumbnailSrc || this.blobSrc || DEFAULT_THUMBNAIL; } } else { this.imgElement.src = this.blobSrc || this.thumbnailSrc || DEFAULT_THUMBNAIL; } }, 30); } unrender() { if (!this.imgElement) return; this.imgElement.src = ""; } progress(state) { if (!this.root) return; if (state.readyState === 4) { if (this.downloadBar && this.downloadBar.parentNode) { this.downloadBar.parentNode.removeChild(this.downloadBar); } return; } if (!this.downloadBar) { const downloadBar = document.createElement("div"); downloadBar.classList.add("download-bar"); downloadBar.innerHTML = ``; this.downloadBar = downloadBar; this.root.firstElementChild.appendChild(this.downloadBar); } if (this.downloadBar) { this.downloadBar.firstElementChild.style.width = state.loaded / state.total * 100 + "%"; } } changeStyle(fetchStatus, failedReason) { if (!this.root) return; const clearClass = () => this.root.classList.forEach((cls) => ["img-excluded", "img-fetching", "img-fetched", "img-fetch-failed"].includes(cls) && this.root?.classList.remove(cls)); if (!this.picked) { clearClass(); this.root.classList.add("img-excluded"); } else { switch (fetchStatus) { case "fetching": clearClass(); this.root.classList.add("img-fetching"); break; case "fetched": clearClass(); this.root.classList.add("img-fetched"); break; case "failed": clearClass(); this.root.classList.add("img-fetch-failed"); break; case "init": clearClass(); break; } } this.root.querySelector(".img-node-error-hint")?.remove(); if (failedReason) { const errorHintElement = document.createElement("div"); errorHintElement.classList.add("img-node-error-hint"); errorHintElement.innerHTML = `${failedReason}