笔趣阁优化 for iOS
// ==UserScript==
// @name 笔趣阁优化 for iOS
// @namespace Violentmonkey Scripts
// @match https://lingjingxingzhe.com/*.html
// @match https://m.qishuta.org/*.html
// @exclude-match https://*/*index*.html
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.deleteValue
// @require https://cdn.jsdelivr.net/npm/alpinejs@3.13.0/dist/cdn.min.js
// @version 2.0.0
// @author LinHQ
// @license GPLv3
// @description 极简的 iOS Safari 端辅助小说阅读脚本
// ==/UserScript==
(async () => {
// 自定义网站配置
let sites = [
{
host: 'lingjingxingzhe.com',
title: 'h1.title',
content: '#content',
filters: ['.header', '.nav', 'body>:not(.container,.m-setting,#ird-main)'],
banRegex: [/打开站内搜索即可阅读/g, /\s?[||]\s?/g]
},
{
host: 'm.qishuta.org',
title: '#chaptertitle',
content: '#novelcontent',
filters: ['.msitetext', 'body > :not(.main,.ird-main)', '#strl', 'script+div', '#novelcontent .novelbutton', '#novelcontent :not(br,hr,h3)', 'body style+div'],
banRegex: [/[((]本章.+?继续阅读[))]/g, /第.+?(<br>){2}/g, /(<br>){2}.+?继续阅读[))]/g, /百度.+?即可阅读/g, /搜.+?最新章节/g, /全网首发/g, /新阅读.+?网站/g, /第.章.+?[))]/g]
}
]
const appTemplate = `
<style>
/*@import url("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.10.0/font/bootstrap-icons.css");*/
[x-cloak] {
/*visibility: hidden;*/
}
.light {
background: rgb(243 243 243);
}
.dark {
background: black;
color: #b8b8b8;
}
.flex {
display: flex;
align-items: center;
gap: 2px;
}
.flex-col {
flex-flow: column;
}
#app {
height: 100vh;
position: fixed;
width: 100vw;
z-index: 999;
top: 0;
left: 0;
transition: background 100ms linear;
}
#ird-contentWrapper {
height: calc(100% - 32px);
overflow-y: auto;
overflow-x: hidden;
padding: 4px;
}
#ird-contentWrapper h1 {
font-size: 1.5rem;
text-align: center;
}
p.html-mode p {
margin: 8px 4px;
line-height: 1.5;
text-indent: 2em;
}
p.text-mode {
line-height: 1.5;
white-space: pre-wrap;
}
#ird-tools {
height: 20vh;
overflow: hidden;
position: fixed;
width: 100%;
bottom: 0;
transform: translateY(calc(100% - 32px));
transition: transform 120ms cubic-bezier(0.72, 0.27, 0.58, 0.96);
}
#ird-tools .tool-bar {
display: flex;
justify-content: center;
align-items: center;
gap: 2px;
height: 32px;
width: 100%;
}
.tool-bar input[type=number] {
margin-left: 2px;
width: 2.5em;
}
#ird-tools.show {
transform: translateY(0);
}
#ird-tools.hide {
transform: translateY(100%);
}
#ird-tools button {
flex: 1 0 60px;
padding: 4px;
border-radius: 4px;
border: 1px solid;
background: none;
}
.dark #ird-tools button {
border-color: rgb(243 243 243);
color: rgb(243 243 243);
}
button[disabled] {
color: gray!important;
border-color: gray!important;
cursor: not-allowed;
}
#ird-flag {
width: 100%;
height: 8px;
border: 1px solid;
}
</style>
<div :class="[config.theme]" :style="{display: hideApp ? 'none' : 'block'}" id="app" x-data="App" x-cloak>
<div id="ird-contentWrapper"
x-ref="contentWrapper"
:style="{fontSize: config.fontSize + 'px', height: config.hideToolbar ? '100vh': 'calc(100% - 32px)'}"
@pointerup.stop="handleTap($event)"
@pointercancel.stop="pointers.clear()"
@pointerdown.stop="pointers.add($event.pointerId)">
<template x-for="{key, doc} in loaded" :key="key">
<div>
<template x-if="config.htmlMode">
<div>
<h1 x-show="doc.showTitle" x-text="doc.currentTitle"></h1>
<p class="html-mode" x-html="doc.html"></p>
</div>
</template>
<template x-if="!config.htmlMode">
<div>
<h1 x-show="doc.showTitle" x-text="doc.currentTitle"></h1>
<p class="text-mode" x-text="doc.text"></p>
</div>
</template>
</div>
</template>
<div x-ref="segment" id="ird-flag"></div>
</div>
<div class="flex flex-col" :class="{'show': config.showTOC, 'hide': config.hideToolbar, 'dark': config.theme=='dark', 'light': config.theme=='light'}" id="ird-tools">
<div class="tool-bar">
<button @click.stop="toToc">目录页</button>
<button @click.stop="toggleParseMode" x-text="config.htmlMode ? '标准模式' : '兼容模式' "></button>
<button @click.stop="toggleTheme()" x-text="(config.theme==='light' ? '关' : '开') + '灯'"></button>
<button @click.stop="config.showTOC=!config.showTOC" x-text="(config.showTOC ? '收起' : '展开') + '更多'"></button>
</div>
<div class="tool-bar">
<button :disabled="loading" @click.stop="navigate('prev')">上一章</button>
<label class="flex">
<span>字号:</span>
<input type="range" step="1" min="14" max="25" @input="config.fontSize = $event.target.value" :value="config.fontSize">
<span x-text="config.fontSize + 'px'"></span>
</label>
<button :disabled="loading" @click.stop="navigate('next')">下一章</button>
</div>
<div style="flex-grow: 1;"></div>
<p>LinHQ1999/LinHQ1999@qq.com</p>
<div x-text="'Version:' + config.version"></div>
</div>
</div>`
function App() {
return {
loading: false,
loaded: [],
// 注意,如果改用 petite-vue 则不支持写在里面,只能挪到外面去
pointers: new Set(),
nextObserver: null,
hideApp: false,
config: {
showTOC: false,
hideToolbar: false,
fontSize: 18,
theme: 'light',
version: '2.0.0',
htmlMode: false,
readingURL: document.URL
},
// 获取阅读信息存储的 key
get storeKey() {
const path = (new URL(document.URL)).pathname.split('/')
path.pop()
return path.pop()
},
async init() {
// 先获取保存的配置
const saved = await GM.getValue(this.storeKey)
if (saved && this.config.version === saved.version) {
if (saved.readingURL && saved.readingURL !== this.config.readingURL) {
if (confirm('是否跳转到上次阅读章节?')) {
document.location = saved.readingURL
}
}
Object.assign(this.config, saved)
this.config.readingURL = document.URL
}
let doc = this.parseDoc(document.body)
this.loaded.push({key: new URL(document.URL).pathname, doc})
this.nextObserver = new IntersectionObserver(this.handleNext.bind(this), {
// 进入可见区域之前进行检测
root: this.$refs.contentWrapper,
rootMargin: '100%'//getComputedStyle(this.$refs.contentWrapper).height
})
// 在初始化元素变更完之后再开始各种监听
await this.switchObserver(true)
this.$watch('config', async () => {
await GM.setValue(this.storeKey, this.config)
})
},
async switchObserver(start) {
if (start) {
await this.$nextTick()
this.nextObserver.observe(this.$refs.segment)
} else {
this.nextObserver.disconnect()
}
},
// 修正 重排模式 下的各种显示问题
fixFormat(text) {
let result = text.replaceAll(/[^\S\n]{2}/g, ' ')
return result
},
// 解析 doc 到 object
parseDoc(doc) {
const result = {}, previous = this.loaded.length > 0 ? this.loaded[this.loaded.length - 1] : null
const currentSite = sites.find(site => document.URL.includes(site.host))
// 解析链接
for (const a of doc.querySelectorAll('a')) {
if (a.textContent.includes('上一'))
result.prev = a.href
else if (a.textContent.includes('下一'))
result.next = a.href
else if (a.textContent.includes('目录')) {
result.toc = a.href
}
}
// 标题取得
result.currentTitle = doc.querySelector(currentSite.title)?.textContent
// 内容取得
const contentEle = doc.querySelector(currentSite.content)
currentSite.filters.forEach(reg => {
contentEle.querySelectorAll(reg).forEach(ele => ele.remove())
})
let html = contentEle.innerHTML
let text = contentEle.textContent
currentSite.banRegex.forEach(reg => {
text = text.replaceAll(reg, '')
html = html.replaceAll(reg, '')
})
result.html = html.replaceAll(/( ){2,}/g, ' ')
result.text = this.fixFormat(text)
// 没有 previous 就是第一个直接解析的页面
result.showTitle = previous ? result.currentTitle !== previous.doc.currentTitle : true
// 虽然说 key 就是这个,但是加上也没啥
result.URL = previous?.doc.next ?? document.URL
return result
},
handleTap(e) {
// 提高多点下效率
if (this.pointers.size === 0) return
const container = this.$refs.contentWrapper
const viewHeight = parseInt(getComputedStyle(container).height),
viewWidth = parseInt(getComputedStyle(container).width)
const pageLength = viewHeight - this.config.fontSize * 1.5
switch (this.pointers.size) {
case 1:
// 不妨直接获取倒数第二块的rect计算滚动距离
if (e.clientX < viewWidth / 3) {
// window.scrollBy(0, -1 * viewHeight - fontSize * lineHeight * 1.5)
container.scrollBy(0, pageLength)
} else if (e.clientX > viewWidth * 3 / 4) {
container.scrollBy(0, pageLength)
} else {}
break
case 3:
this.config.hideToolbar = !this.config.hideToolbar
break
}
this.pointers.clear()
},
toggleTheme() {
this.config.theme = this.config.theme === 'light' ? 'dark' : 'light'
},
async handleNext(entries) {
if (!entries[0].isIntersecting) {
return
}
await this.switchObserver(false)
// 下面开始执行无限加载
try {
const {doc: now} = this.loaded[this.loaded.length - 1]
const newDoc = await this.fetchDocument(now.next)
const newParsedDoc = this.parseDoc(newDoc)
this.loaded.push({key: now.next, doc: newParsedDoc})
// 新加载的可能还没滚到,所以还是保存上一章的 URL
this.config.readingURL = now.URL
} catch (e) {
console.error(e)
} finally {
await this.switchObserver(true)
}
},
// html 直拼还是 textContent 修正
async toggleParseMode() {
await this.switchObserver(false)
this.config.htmlMode = !this.config.htmlMode
await this.switchObserver(true)
},
async navigate(action) {
await this.switchObserver(false)
const current = this.loaded[this.loaded.length > 2 ? this.loaded.length - 2 : 0].doc
const cached = this.loaded.find(rec => rec.key === current[action])
// 切换之后无论如何显示标题
if (cached) {
cached.doc.showTitle = true
this.loaded = [cached]
} else {
const page = await this.fetchDocument(current[action])
const doc = this.parseDoc(page)
doc.showTitle = true
this.loaded = [{key: current[action], doc}]
}
this.config.readingURL = current[action]
await this.switchObserver(true)
},
toToc() {
this.hideApp = true
this.config.readingURL = undefined
document.location = this.loaded[0].doc.toc
},
async fetchDocument(url) {
this.loading = true
const page = await fetch(url).then(resp => resp.arrayBuffer()),
decoder = new TextDecoder(document.characterSet)
this.loading = false
return new DOMParser().parseFromString(decoder.decode(page), 'text/html')
}
}
}
// 用 WebComponent
class IReader extends HTMLElement {
constructor() {
super()
this.root = this.attachShadow({mode: 'closed'})
this.root.innerHTML = appTemplate
}
connectedCallback() {
Alpine.data('App', App)
// closed shadowRoot 无法通过遍历获取,需要传给 Alpine
Alpine.initTree(this.root)
}
}
customElements.define('i-reader', IReader)
const ird = document.createElement('i-reader')
ird.id = 'ird-main'
ird.className = 'ird-main show'
document.body.appendChild(ird)
})()