导出帖子内容小脚本(markdown)

导出帖子内容小脚本(markdown)
导出帖子内容小脚本(markdown)

有没有什么办法能让AI读本站文章继续讨论:

最近在看一些帖子也有类似需求,方便跟AI 讨论,就顺手让Claude 撸了一个。用的是Discourse 自带的功能:

有没有什么办法能让AI读本站文章 开发调优
Discourse 的话,可以通过替换链接拿到markdown 格式的内容,直接复制给AI 会更好 比如这个帖子的链接是:https://linux.do/t/topic/1984943,把t/topic/替换成raw/ :https://linux.do/raw/1984943 需要分页加:?page=1

脚本会在主贴和最后的回复出新增一个image按钮,点击导出成markdown 格式的文件。
性能开销应该比较小,不会对网站造成什么负担[1],当然你也可以用的时候再收到打开脚本。

篡改猴:

// ==UserScript==
// @name         Linux.do 导出 Markdown
// @namespace    https://linux.do/
// @version      0.0.4
// @description  在 linux.do 帖子页面所有 topic-map 处新增「导出 MD」按钮,将帖子内容导出为 Markdown 文件
// @author       hwang
// @match        https://linux.do/t/topic/*
// @grant        GM_xmlhttpRequest
// @connect      linux.do
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ── 工具函数 ──────────────────────────────────────────────────
  function getTopicId() {
    const m = location.pathname.match(/\/t\/[^/]+\/(\d+)/);
    return m ? m[1] : null;
  }

  function getCurrentPage() {
    const p = new URLSearchParams(location.search).get('page');
    return p ? parseInt(p, 10) : 1;
  }

  function getTopicTitle() {
    const el = document.querySelector('h1.fancy-title, h1[data-topic-id], .topic-title h1, .fancy-title');
    return el ? el.textContent.trim() : `topic-${getTopicId()}`;
  }

  function safeFilename(name) {
    return name.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, '_').slice(0, 100);
  }

  function fetchRaw(topicId, page) {
    return new Promise((resolve, reject) => {
      let url = `https://linux.do/raw/${topicId}`;
      if (page && page > 1) url += `?page=${page}`;
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        onload(res) {
          if (res.status === 200) resolve(res.responseText);
          else reject(new Error(`HTTP ${res.status}: ${url}`));
        },
        onerror(err) {
          reject(new Error('网络错误: ' + JSON.stringify(err)));
        },
      });
    });
  }

  function downloadText(filename, content) {
    const blob = new Blob([content], { type: 'text/markdown;charset=utf-8' });
    const url  = URL.createObjectURL(blob);
    const a    = Object.assign(document.createElement('a'), { href: url, download: filename });
    document.body.appendChild(a);
    a.click();
    setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
  }

  // ── 弹窗 UI ───────────────────────────────────────────────────
  function createModal() {
    const overlay = document.createElement('div');
    Object.assign(overlay.style, {
      position: 'fixed', inset: '0',
      background: 'rgba(0,0,0,.55)',
      zIndex: '99998',
      display: 'flex', alignItems: 'center', justifyContent: 'center',
    });

    const box = document.createElement('div');
    Object.assign(box.style, {
      background: '#1e2229', color: '#d4d7dc',
      borderRadius: '10px', padding: '28px 32px', width: '360px',
      boxShadow: '0 8px 32px rgba(0,0,0,.6)', fontFamily: 'inherit',
      position: 'relative', zIndex: '99999',
    });

    box.innerHTML = `
      <h3 style="margin:0 0 18px;font-size:16px;color:#fff;">📥 导出 Markdown</h3>
      <label style="display:block;margin-bottom:10px;font-size:13px;">
        导出范围
        <select id="ld-export-mode" style="display:block;width:100%;margin-top:6px;padding:7px 10px;
          background:#2c3038;border:1px solid #404552;border-radius:6px;color:#d4d7dc;font-size:13px;cursor:pointer;">
          <option value="first">仅第 1 页(默认)</option>
          <option value="current">当前页</option>
          <option value="custom">指定页码</option>
          <option value="all">全部页(逐页合并)</option>
        </select>
      </label>
      <div id="ld-custom-wrap" style="display:none;margin-bottom:10px;">
        <label style="font-size:13px;">页码
          <input id="ld-custom-page" type="number" min="1" value="1" style="display:block;width:100%;
            margin-top:6px;padding:7px 10px;background:#2c3038;border:1px solid #404552;
            border-radius:6px;color:#d4d7dc;font-size:13px;box-sizing:border-box;" />
        </label>
      </div>
      <div id="ld-status" style="font-size:12px;color:#9ba0ab;min-height:20px;margin-bottom:14px;"></div>
      <div style="display:flex;gap:10px;justify-content:flex-end;">
        <button id="ld-cancel-btn" style="padding:8px 18px;border-radius:6px;border:1px solid #404552;
          background:transparent;color:#9ba0ab;cursor:pointer;font-size:13px;">取消</button>
        <button id="ld-export-btn" style="padding:8px 18px;border-radius:6px;border:none;
          background:#0088cc;color:#fff;cursor:pointer;font-size:13px;font-weight:600;">导出</button>
      </div>
    `;

    overlay.appendChild(box);
    document.body.appendChild(overlay);

    const modeSelect  = box.querySelector('#ld-export-mode');
    const customWrap  = box.querySelector('#ld-custom-wrap');
    const customInput = box.querySelector('#ld-custom-page');
    const statusEl    = box.querySelector('#ld-status');
    const exportBtn   = box.querySelector('#ld-export-btn');
    const cancelBtn   = box.querySelector('#ld-cancel-btn');

    modeSelect.addEventListener('change', () => {
      customWrap.style.display = modeSelect.value === 'custom' ? 'block' : 'none';
      if (modeSelect.value === 'current') customInput.value = getCurrentPage();
    });

    const closeModal = () => overlay.remove();
    cancelBtn.addEventListener('click', closeModal);
    overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); });

    exportBtn.addEventListener('click', async () => {
      const topicId = getTopicId();
      if (!topicId) { statusEl.textContent = '❌ 无法解析 Topic ID'; return; }
      exportBtn.disabled = true;
      exportBtn.textContent = '导出中…';
      statusEl.textContent = '';
      try {
        const mode = modeSelect.value;
        const title = getTopicTitle();
        let content = '';

        if (mode === 'first') {
          statusEl.textContent = '正在拉取第 1 页…';
          content = await fetchRaw(topicId, 1);
        } else if (mode === 'current') {
          const p = getCurrentPage();
          statusEl.textContent = `正在拉取第 ${p} 页…`;
          content = await fetchRaw(topicId, p);
        } else if (mode === 'custom') {
          const p = parseInt(customInput.value, 10) || 1;
          statusEl.textContent = `正在拉取第 ${p} 页…`;
          content = await fetchRaw(topicId, p);
        } else if (mode === 'all') {
          const parts = [];
          let page = 1;
          while (true) {
            statusEl.textContent = `正在拉取第 ${page} 页…`;
            let text;
            try { text = await fetchRaw(topicId, page); } catch (e) { break; }
            if (!text || text.trim() === '') break;
            parts.push(`<!-- Page ${page} -->\n${text}`);
            if (page > 1 && parts[page - 1].trim() === parts[page - 2].trim()) {
              parts.pop(); break;
            }
            page++;
            await new Promise(r => setTimeout(r, 300));
          }
          content = parts.join('\n\n---\n\n');
        }

        if (!content) { statusEl.textContent = '⚠️ 未获取到内容,请确认页码是否有效'; return; }
        const filename = `${safeFilename(title)}.md`;
        downloadText(filename, content);
        statusEl.style.color = '#4caf50';
        statusEl.textContent = `✅ 已下载:${filename}`;
        setTimeout(closeModal, 1500);
      } catch (err) {
        statusEl.style.color = '#f44336';
        statusEl.textContent = '❌ ' + err.message;
      } finally {
        exportBtn.disabled = false;
        exportBtn.textContent = '导出';
      }
    });
  }

  // ── 注入按钮 ──────────────────────────────────────────────────
  function findButtonContainer(topicMap) {
    for (const sel of ['.topic-map__buttons', '.topic-footer-main-controls', '.buttons']) {
      const el = topicMap.querySelector(sel);
      if (el) return el;
    }
    const summarizeBtn = topicMap.querySelector(
      '.summarize-topic, button.create-summary, button.topic-map__summarize-btn, button'
    );
    if (summarizeBtn) return summarizeBtn.parentElement;
    const children = [...topicMap.children].filter(
      el => el.tagName === 'DIV' || el.tagName === 'SECTION'
    );
    return children.length ? children[children.length - 1] : null;
  }

  function createButton() {
    const btn = document.createElement('button');
    btn.className = 'ld-export-md-btn';
    btn.textContent = '⬇ 导出 MD';

    // 获取参考样式(只在创建时获取一次)
    const refBtn = document.querySelector('.topic-map button');
    const refStyle = refBtn ? getComputedStyle(refBtn) : null;

    Object.assign(btn.style, {
      padding: '6px 14px',
      borderRadius: refStyle ? refStyle.borderRadius : '6px',
      border: '1px solid #404552',
      background: '#2c3038',
      color: refStyle ? refStyle.color : '#d4d7dc',
      cursor: 'pointer',
      fontSize: refStyle ? refStyle.fontSize : '13px',
      fontWeight: '600',
      marginRight: '8px',
      verticalAlign: 'middle',
      transition: 'background .2s',
      flexShrink: '0',
    });

    btn.addEventListener('mouseover', () => btn.style.background = '#3a3f4b');
    btn.addEventListener('mouseout',  () => btn.style.background = '#2c3038');

    return btn;
  }

  function injectButton(topicMap) {
    if (topicMap.querySelector('.ld-export-md-btn')) return false;

    const container = findButtonContainer(topicMap);
    if (!container) return false;

    const btn = createButton();
    container.insertBefore(btn, container.firstChild);
    return true;
  }

  // ── 性能优化核心:Intersection Observer + 事件委托 ──────────

  const injectedMaps = new WeakSet();

  // 使用事件委托,只绑定一次
  document.body.addEventListener('click', (e) => {
    if (e.target.classList.contains('ld-export-md-btn')) {
      e.preventDefault();
      e.stopPropagation();
      createModal();
    }
  });

  // Intersection Observer:只在元素可见时注入
  const intersectionObserver = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting && !injectedMaps.has(entry.target)) {
        if (injectButton(entry.target)) {
          injectedMaps.add(entry.target);
          // 注入成功后停止观察该元素
          intersectionObserver.unobserve(entry.target);
        }
      }
    });
  }, {
    threshold: 0.1,
    rootMargin: '50px' // 提前 50px 开始加载
  });

  // MutationObserver:只监听主内容区域,不监听整个 body
  let mutationTimer = null;
  const contentArea = document.querySelector('#main-outlet, #topic, .container.posts') || document.body;

  const mutationObserver = new MutationObserver(() => {
    if (mutationTimer) return;
    mutationTimer = setTimeout(() => {
      mutationTimer = null;

      // 只查找新出现的 topic-map
      const maps = contentArea.querySelectorAll('.topic-map');
      maps.forEach(map => {
        if (!injectedMaps.has(map)) {
          intersectionObserver.observe(map);
        }
      });
    }, 800); // 较长的防抖时间
  });

  mutationObserver.observe(contentArea, {
    childList: true,
    subtree: true
  });

  // 初始化:立即处理已存在的元素
  document.querySelectorAll('.topic-map').forEach(map => {
    intersectionObserver.observe(map);
  });

  // ── 路由跳转处理 ──────────────────────────────────────────────
  function cleanup() {
    intersectionObserver.disconnect();
    mutationObserver.disconnect();
  }

  function reinit() {
    cleanup();

    // 重新初始化
    const newContentArea = document.querySelector('#main-outlet, #topic, .container.posts') || document.body;
    mutationObserver.observe(newContentArea, { childList: true, subtree: true });

    document.querySelectorAll('.topic-map').forEach(map => {
      if (!injectedMaps.has(map)) {
        intersectionObserver.observe(map);
      }
    });
  }

  window.addEventListener('popstate', reinit);

  const _pushState = history.pushState.bind(history);
  history.pushState = function (...args) {
    _pushState(...args);
    setTimeout(reinit, 100); // 延迟等待 DOM 更新
  };

})();

  1. 仅对前端页面修改,也不会对服务器造成压力 ↩︎

1 个帖子 - 1 位参与者

阅读完整话题

来源: linux.do查看原文