优化Readwise阅读体验:保持原始格式和符号位置,实现智能分句,处理特殊光标符号,并通过<br>标签转换实现段落自动缩进
// ==UserScript==
// @name Readwise 阅读优化
// @namespace readwise.reader
// @version 3.1.0
// @description 优化Readwise阅读体验:保持原始格式和符号位置,实现智能分句,处理特殊光标符号,并通过<br>标签转换实现段落自动缩进
// @match https://read.readwise.io/*
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
// -----------------------------
// 1. 添加自定义字体与段落样式
// -----------------------------
const styleSheet = document.createElement('style');
styleSheet.textContent = `
@font-face {
font-family: "仓耳华新体";
src: url("https://ziti-beta.vercel.app/fonts/仓耳华新体.ttf") format("truetype");
font-weight: normal;
font-style: normal;
}
.document-content, .document-content * {
font-family: "仓耳华新体", sans-serif !important;
}
/* 为段落添加 2em 首行缩进 */
.document-content p {
text-indent: 2em !important;
margin-bottom: 1em !important;
line-height: 1.8 !important;
display: block !important;
}
/* 针对 blockquote、列表等的特殊处理,避免重复缩进 */
.document-content p blockquote {
text-indent: 0 !important;
}
.document-content li p {
text-indent: 0 !important;
}
/* 如果不想 em 标签清除缩进,可以注释掉以下规则
.document-content p em {
display: block !important;
margin: 0.5em 0 !important;
text-indent: 0 !important;
} */
/* 若段落以 <br> 开头,则隐藏这个首行换行 */
.document-content p[data-formatted="true"] br:first-child {
display: none !important;
}
[class*='_contentRow_'] {
max-height: 80px !important;
overflow: hidden !important;
position: relative !important;
}
[class*='_contentRow_']:hover {
max-height: 80px !important;
overflow: hidden !important;
}
[class*='_description_'] {
overflow: hidden !important;
text-overflow: ellipsis !important;
display: -webkit-box !important;
-webkit-line-clamp: 2 !important;
-webkit-box-orient: vertical !important;
}
`;
document.head.appendChild(styleSheet);
// -----------------------------
// 2. 分割文本中 <br> 并生成多段 p
// -----------------------------
function splitParagraphsOnBr(p) {
const html = p.innerHTML;
if (html.includes('<br')) {
const parts = html.split(/<br\s*\/?>/i).map(s => s.trim()).filter(s => s);
if (parts.length > 1) {
const fragment = document.createDocumentFragment();
let buffer = '';
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
const hasOpenQuote = (buffer + part).match(/[「『《]/g)?.length || 0;
const hasCloseQuote = (buffer + part).match(/[」』》]/g)?.length || 0;
// 如果出现不对称引号,则继续拼接
if (hasOpenQuote > hasCloseQuote && i < parts.length - 1) {
buffer += part + ' ';
continue;
}
const newP = document.createElement('p');
newP.innerHTML = buffer + part;
newP.dataset.formatted = 'true';
fragment.appendChild(newP);
buffer = '';
}
if (buffer) {
const newP = document.createElement('p');
newP.innerHTML = buffer.trim();
newP.dataset.formatted = 'true';
fragment.appendChild(newP);
}
p.parentNode.replaceChild(fragment, p);
}
}
}
// -----------------------------
// 3. 处理微信链接,把它变成单独段落
// -----------------------------
function processWechatLink(para) {
const fragment = document.createDocumentFragment();
const links = Array.from(para.getElementsByTagName('a'));
let replacedAny = false;
links.forEach(link => {
if (link.href && link.href.includes('__biz=')) {
const text = link.textContent;
// 有些链接含有 '>' 字符,不一定是 Markdown 形式
// 可以直接将 link.textContent 当作标题,不依赖正则
const newLink = link.cloneNode(true);
newLink.textContent = text.trim();
// 创建新段落
const newP = document.createElement('p');
newP.dataset.formatted = 'true';
newP.appendChild(newLink);
fragment.appendChild(newP);
replacedAny = true;
}
});
if (replacedAny) {
// 若本段还有其他纯文本,需要再插回去(看你是否想保留原段落)
// 这里演示:如果链接覆盖了整个段落,就直接用链接段落替换原段落
// 若想保留原文本 + 链接,可以把剩余文本另行处理
para.parentNode.replaceChild(fragment, para);
return true;
}
return false;
}
// -----------------------------
// 4. 统一分段、分句:核心函数
// -----------------------------
function formatAllParagraphs() {
const paras = document.querySelectorAll('.document-content p:not([data-formatted])');
let formattedCount = 0;
paras.forEach(para => {
let html = para.innerHTML.trim();
if (!html) {
para.dataset.formatted = 'true';
return;
}
// 先检查是否有 <br>,如果有则拆分段落
splitParagraphsOnBr(para);
if (para.dataset.formatted === 'true') {
// splitParagraphsOnBr 已替换掉原 p
return;
}
// 再检查是否包含微信文章链接
if (html.includes('__biz=')) {
if (processWechatLink(para)) {
// 已被替换
return;
}
}
// 如果段落包含 Markdown 类似 [链接] http://xxx
// 你可自行保留或注释这段逻辑
if (html.includes('[') && html.includes(']') && html.includes('http')) {
const links = html.match(/\[.*?\].*?(?=\[|$)/g);
if (links) {
const fragment = document.createDocumentFragment();
links.forEach(link => {
const newP = document.createElement('p');
newP.innerHTML = link.trim();
newP.dataset.formatted = 'true';
fragment.appendChild(newP);
});
para.parentNode.replaceChild(fragment, para);
return;
}
}
// 对没有 <br> 的段落进行中文标点分句
// 用 <split> 标记断句后,再拆分成多个 <p>
html = html.replace(/([。!?!?])([^"'」』》])/g, '$1<split>$2');
let segments = html.split(/<split>/).map(s => s.trim()).filter(s => s);
if (segments.length <= 1) {
// 无需再拆分
para.dataset.formatted = 'true';
return;
}
// 生成新的段落
const fragment = document.createDocumentFragment();
segments.forEach(segment => {
const newP = document.createElement('p');
newP.innerHTML = segment;
newP.dataset.formatted = 'true';
fragment.appendChild(newP);
});
para.parentNode.replaceChild(fragment, para);
formattedCount += segments.length;
});
if (formattedCount > 0) {
console.log(`分句成功:${formattedCount} 个段落已重新分段`);
}
return formattedCount;
}
// -----------------------------
// 5. 使用 MutationObserver + 定时器
// -----------------------------
const observer = new MutationObserver(() => {
formatAllParagraphs();
});
observer.observe(document.body, { childList: true, subtree: true });
document.addEventListener('DOMContentLoaded', () => {
formatAllParagraphs();
setTimeout(formatAllParagraphs, 500);
setTimeout(formatAllParagraphs, 1000);
setTimeout(formatAllParagraphs, 2000);
});
let attempts = 0;
const maxAttempts = 20;
const intervalId = setInterval(() => {
const count = formatAllParagraphs();
attempts++;
if (count > 0 || attempts >= maxAttempts) {
clearInterval(intervalId);
}
}, 500);
window.addEventListener('load', () => {
formatAllParagraphs();
});
})();