自动展开全文(永久 beta 版)

自动展开全文的 beta 版,大概永远不会正式。使用前请务必认真阅读发布页说明,安装即表示知悉、理解并同意说明中全部内容。此版本不包含任何 match 信息,请自行添加。

Tính đến 08-08-2020. Xem phiên bản mới nhất.

// ==UserScript==
// @name        自动展开全文(永久 beta 版)
// @namespace   Expand the article for vip.
// @match       none
// @grant       GM_addStyle
// @grant       GM_getValue
// @run-at      document-end
// @version     0.0.10
// @supportURL  https://docs.qq.com/form/page/DYVFEd3ZaQm5pZ1ZR
// @homepageURL https://meta.appinn.net/t/17991
// @inject-into content
// @author      稻米鼠
// @created     2020-07-24 07:04:35
// @updated     2020-08-08 17:17:38
// @description 自动展开全文的 beta 版,大概永远不会正式。使用前请务必认真阅读发布页说明,安装即表示知悉、理解并同意说明中全部内容。此版本不包含任何 match 信息,请自行添加。
// ==/UserScript==

(function(){
// 闭包 Start
/**
 * Tag: 【Data】正文判定,特定子元素数量
 * 设定控制阈值,当元素的子元素是特定元素的数量超过此值,当作正文处理
 */
const passagesMinCount = 3
/**
 * Tag: 【Data】正文判定,字符阈值
 * 设定控制阈值,当元素内文字字数超过此值,当作正文处理
 */
const contentMinCount = 200
// 添加 debug 输出函数
/**
 * Tag: 【Data】Debug 状态声明
 */
let is_debugger = false
try {
  is_debugger = GM_getValue('debugger', false)
} catch (error) {}
/**
 * Tag: 【Func】Debug 输出
 * @param {*} content 输出内容。若为数组,则以组的形式输出
 * @param {*} by 输出来源
 */
const dglog = (content, by='Default')=>{
  if(is_debugger){
    if(content instanceof Array){
      console.groupCollapsed(by+': ')
      for(const c of content){
        console.info(c)
      }
      console.groupEnd()
    }else{
      console.info(by+': ', content)
    }
  }
}
/**
 * Tag: 添加样式
 * 加入基础样式信息,后面通过为元素添加相应类来实现展开
 */
const cssString = `
  .expand-the-article-no-limit {
    max-height: none !important;
    height: auto !important;
  }
  .expand-the-article-display-none { display: none !important; }
  `
try {
  GM_addStyle(cssString)
} catch (error) {
  // 重写添加 style 功能,以兼容无接口的运行环境
  const style = document.createElement('style')
  // 添加额外 ID 便于识别
  style.id = 'expand-the-article-for-me'
  style.innerHTML = cssString
  document.head.appendChild(style)
}

/**
 * Tag: 【Func】元素过滤器
 * 对每个元素进行分析,是否为(疑似)正文元素
 * @param {*} el 待判断元素
 * @param {*} is_rollback 是否为回退判断,默认为 false
 * @returns
 * 是类正文元素返回  true
 * 无需处理元素返回  false
 * 非类正文元素返回  0
 */
const elFilter = (el, is_rollback=false)=>{
  try {
    // 非元素,无需处理
    if(!el) return false
    // 特定标签,无需处理
    const excludeTags = [
      'a',
      'abbr',
      'applet',
      'area',
      'audio',
      'b',
      'base',
      'bdi',
      'bdo',
      'body',
      'br',
      'button',
      'canvas',
      'caption',
      'cite',
      'code',
      'col',
      'colgroup',
      'data',
      'datalist',
      'del',
      'details',
      'dfn',
      'dialog',
      'em',
      'embed',
      'fieldset',
      'form',
      'g',
      'head',
      'html',
      'i',
      'img',
      'input',
      'ins',
      'kbd',
      'label',
      'legend',
      'link',
      'map',
      'mark',
      'marquee',
      'menu',
      'menuitem',
      'meta',
      'meter',
      'noscript',
      'optgroup',
      'option',
      'output',
      'param',
      'picture',
      'pre',
      'progress',
      'q',
      'rb',
      'rp',
      'rt',
      'rtc',
      'ruby',
      's',
      'samp',
      'script',
      'select',
      'small',
      'source',
      'span',
      'strong',
      'style',
      'sub',
      'summary',
      'sup',
      'svg',
      'table',
      'tbody',
      'td',
      'textarea',
      'tfoot',
      'th',
      'thead',
      'time',
      'title',
      'tr',
      'track',
      'tt',
      'u',
      'var',
      'video',
      'wbr',
    ];
    if(excludeTags.indexOf(el.tagName.toLowerCase()) !== -1){
      return false
    }
    // 统计元素中特定子元素的个数,判断是否属于正文
    if(is_rollback){
      // 如果是回退情况,直接计算所有后代元素
      if (
        el.querySelectorAll('p, br, h1, h2, h3, h4, h5, h6').length >=
        passagesMinCount
      )
        return true;
    }else{
      // 如果不是回退判断
      let passages = 0
      const children = el.children
      for(let i=0; i<children.length; i++){
        if(/^(p|br|h1|h2|h3|h4|h5|h6)$/i.test(children[i].tagName)){
          passages++
        }
      }
      if(passages >= passagesMinCount){
        dglog([el, '子元素', passages], 'elFitler 子元素 '+el.tagName+' - '+el.classList.toString())
        return true
      }
    }
    // 如果有文字内容,并且字数大于阈值
    if(el.innerText && el.innerText.length >= contentMinCount){
      dglog([el, '文字数', el.innerText.length, el.innerText], 'elFitler 文字数 '+el.tagName+' - '+el.classList.toString())
      return true
    }
  } catch (error) {}
  return 0
}
/**
 * Tag: 【Func】移除[阅读更多]按钮
 * 移除可能的 阅读更多 按钮
 * @param {*} el 待处理元素
 * @param {*} index 当前处理层级,超出一定深度则跳出
 */
const removeReadMoreButton = (el, index=0)=>{
  if(index>=2) return // 只处理两层深度
  for(const e of el.children){
    // 定位元素或者上方外部为负,则隐藏
    const eStyle = window.getComputedStyle(e)
    if (
      (/^(absolute)$/i.test(eStyle.position) && e.innerText.length < 100) ||
      /^-\d/i.test(eStyle.marginTop)
    ) {
      e.classList.add('expand-the-article-display-none');
    }
    // 递归
    removeReadMoreButton(e, ++index)
  }
}
/**
 * Tag: 【Func】移除高度限定
 * 移除元素高度限制,会尝试处理正文元素的所有祖先元素
 * @param {*} el 待处理元素
 */
const removeHeightLimit = el=>{
  // 如果元素标签是 html 或 body 则返回
  if(/^(html|body)$/i.test(el.tagName)) return
  // 如果包含特定类名(表示已处理过),则返回
  if(el.classList.contains('expand-the-article-no-limit')) return
  // 如果存在高度限制,或者隐藏内容,则去除
  const elStyle = window.getComputedStyle(el)
  if (
    elStyle.maxHeight !== 'none' ||
    (elStyle.height !== 'auto' && elStyle.overflowY === 'hidden')
  ) {
    el.classList.add('expand-the-article-no-limit');
  }
  // 寻找并移除 阅读更多 按钮
  removeReadMoreButton(el)
  // 递归处理祖先元素
  if(el.parentElement) removeHeightLimit(el.parentElement)
}
/**
 * Tag: 【Func】元素回退函数
 * 如果元素内不太可能包含正文,并且具有移除高度限定的类,则去除此类
 * @param {*} el 待处理元素
 */
const rollbackEl = el=>{
  if(elFilter(el) === 0){
    if (el.classList && el.classList.contains('expand-the-article-no-limit')) {
      el.classList.remove('expand-the-article-no-limit');
      dglog(el.tagName+' - '+el.classList.toString, 'Rollback')
    }
    // 非类正文元素,则取消它后代元素中所有的隐藏
    el.querySelectorAll('.expand-the-article-display-none').forEach(e=>{
      e.classList.remove('expand-the-article-display-none')
    })
    rollbackEl(el.parentElement)
  }
}
/**
 * Tag: 【Func】元素变化处理
 * @param {*} records 元素变化记录
 */
const whenChange = async records => {
  // 改变的类型为 characterData,对发生变化的元素进行处理
  const elGroupChange = records
    .filter( (el) => /^characterData$/i.test(el.type))
    .map((el) => el.target);
  // 改变的类型为 childList,对添加和删除元素分别处理
  const elGroupAdd = []
  const elGroupRemove = []
  records
    .filter((el) => /^childList$/i.test(el.type))
    .forEach((el) => {
      el.addedNodes.forEach(node => elGroupAdd.push(node));
      el.removedNodes.forEach(node => elGroupRemove.push(node.parentElement));
    });
  // 进行同步遍历
  for await (const el of elGroupChange.concat(elGroupAdd)){
    if(elFilter(el)){ removeHeightLimit(el) }
  }
  for await (const el of elGroupRemove){
    rollbackEl(el)
  }
}
// Tag: 开始全局处理
dglog('全局过滤开始', 'State')
document.querySelectorAll('*').forEach((el)=>{
  if(elFilter(el)) removeHeightLimit(el)
})
// Tag: 【Data】页面监控选项
const obOptions = {
  childList: true,
  subtree: true,
  attributes: true,
  characterData: true,
  attributeOldValue: false,
  characterDataOldValue: false,
  attributeFilter: [],
};
// Tag: 页面变化处理
const observer = new MutationObserver(async (records, observer) => {
  observer.disconnect();
  dglog('元素发生变化', 'State')
  await whenChange(records)
  // 页面处理完成之后重新监控页面变化
  observer.observe(document.body, obOptions);
});
// Tag: 页面监控启动
// observer.observe(document.body, obOptions);
// 闭包 End
})()
长期地址
遇到问题?请前往 GitHub 提 Issues。