分享一个浏览器本地抓取帖子并导出 TXT 的小脚本,适合长帖备份 / AI 总结 / 搜索

之前我以为这类东西可能不能发,最近看到有人发要下载的插件还收获很多赞,那还不如我这个。。开源给大家用,自己写的用了一年多了

脚本本质上只是:

  1. 在你自己浏览器里读取当前页面已经渲染出来的文字;

  2. 自动滚动页面,触发懒加载 / 虚拟列表继续显示楼层;

  3. 对楼层文本做简单去重;

  4. 最后在本地导出一个 TXT 文件。

它不登录、不绕权限、不请求后台接口、不上传数据,也不改网页内容。用途主要是长帖备份、全文搜索、丢给 AI 总结、或者把有用讨论保存下来。

想冲钛,所以整理一下发出来。如果版规不合适,版主可删。


使用方法

以 Chrome / Edge 为例。

1. 打开你想导出的帖子页面

先正常打开帖子。如果页面需要登录才能看,先登录。

2. 打开开发者工具

按:

  • Windows:F12Ctrl + Shift + J

  • Mac:Cmd + Option + J

切到 Console 标签页。

如果浏览器提示不要粘贴陌生代码,这是正常安全提示。这个脚本只在本地读取页面文字并导出 TXT。你也可以先自己看一遍代码。

3. 粘贴脚本并回车

粘贴下面代码,回车执行。

页面右下角会出现一个小黑框,里面有:

  • 开始

  • 停止

  • 导出TXT

4. 点击"开始"

脚本会从页面顶部开始,边滚动边记录楼层文本。

右下角会显示:

已记录 X 楼 | 最近 Y 轮无新增…

5. 等它完成后点击"导出TXT"

会下载一个类似这样的文件:

thread_export_20260602193422.txt

每个楼层之间用:

----

分隔。

脚本

// ===== 配置(可留空自动探测)=====
const OVERRIDE_POST_SELECTOR = ''; // 例: '.post-item', 'li.reply'

// ===== 轻量 UI =====
(() => {
  const id='__grab_txt2_ui__'; if(document.getElementById(id)) return;
  const box=document.createElement('div');
  box.id=id; box.style.cssText='position:fixed;right:16px;bottom:16px;z-index:2147483647;background:#111;color:#fff;padding:10px 12px;border-radius:10px;font:12px/1.4 monospace;box-shadow:0 2px 12px rgba(0,0,0,.5)';
  box.innerHTML=`
    <div style="margin-bottom:6px">楼层抓取 → TXT(抗虚拟列表)</div>
    <button id="gt2_start">开始</button>
    <button id="gt2_stop" disabled>停止</button>
    <button id="gt2_export" disabled>导出TXT</button>
    <label style="margin-left:6px;color:#bbb"><input id="gt2_autoscroll" type="checkbox" checked/> 自动滚</label>
    <div id="gt2_stat" style="margin-top:6px;color:#ccc">待开始…</div>`;
  document.body.appendChild(box);
  gt2_start.onclick=start; gt2_stop.onclick=()=>abortFlag=true; gt2_export.onclick=exportTXT;
})();

// ===== 工具 =====
const sleep = ms => new Promise(r=>setTimeout(r,ms));
const hash  = s => {let h=0,i=0;for(;i<s.length;)h=(h<<5)-h+s.charCodeAt(i++)|0;return h.toString(36);};
const $$    = sel => Array.from(document.querySelectorAll(sel));
const nowStamp = () => new Date().toISOString().replace(/[-:T]/g,'').slice(0,14);

let abortFlag=false;
let seen=new Set();     // 去重
let textBlocks=[];      // 逐层文本

function autodetectSelector(){
  if (OVERRIDE_POST_SELECTOR) return OVERRIDE_POST_SELECTOR;
  const C=['article','.post','.comment','li[data-post-id]','[data-comment-id]','[role="article"]','.reply','.item','.message','.thread-item'];
  const scored=[];
  for (const s of C){
    const els=$$(s).filter(e=>e.innerText?.trim().length>20);
    if(els.length>=5) scored.push({s, n:els.length});
  }
  scored.sort((a,b)=>b.n-a.n);
  return scored[0]?.s || 'article';
}

function extractText(el){
  // 如需更干净,可改成:el.querySelector('.content')?.innerText
  let t=(el.innerText||'').replace(/\u00A0/g,' ').replace(/\s+\n/g,'\n').trim();
  return t.length>=10 ? t : '';
}

function exportTXT(){
  const sep = '\n\n----\n\n';
  const blob = new Blob([textBlocks.join(sep)], {type:'text/plain;charset=utf-8'});
  const a = document.createElement('a');
  a.href = URL.createObjectURL(blob);
  a.download = `thread_export_${nowStamp()}.txt`;
  a.click();
  URL.revokeObjectURL(a.href);
}

// ===== 主逻辑:仅以“是否有新楼层”判停 =====
async function start(){
  abortFlag=false; seen.clear(); textBlocks.length=0;
  const stat   = gt2_stat;
  const bStart = gt2_start, bStop=gt2_stop, bExp=gt2_export, auto=gt2_autoscroll;
  bStart.disabled=true; bStop.disabled=false; bExp.disabled=true;

  const POST_SELECTOR = autodetectSelector();
  stat.textContent = `选择器: ${POST_SELECTOR} | 抓取中…`;
  window.scrollTo({top:0,behavior:'auto'}); await sleep(300);

  // 反复滚动;若多轮没有任何新楼层,则认定“到底/加载不再推进”
  let noNewRounds=0;
  const NO_NEW_LIMIT = 40;        // 连续多少轮没有新楼层才停(可调大)
  const STEP_BASE = Math.max(600, Math.floor(window.innerHeight*0.85));

  // 为了更好触发懒加载:偶尔小幅上/下抖动一下(部分站点用交叉观察器)
  let jiggleTick = 0;

  while(!abortFlag){
    let newInThisRound = 0;
    const nodes = $$(POST_SELECTOR).filter(e => e.offsetParent !== null);
    for (const el of nodes){
      const txt = extractText(el);
      if (!txt) continue;
      const key = hash(txt.slice(0,2000));
      if (seen.has(key)) continue;
      seen.add(key);
      textBlocks.push(txt);
      newInThisRound++;
    }

    if (newInThisRound === 0) noNewRounds++;
    else noNewRounds = 0;

    stat.textContent = `已记录 ${textBlocks.length} 楼 | 最近${noNewRounds}轮无新增…`;

    // 判停:仅当“连续无新增”达到阈值才停
    if (noNewRounds >= NO_NEW_LIMIT) break;

    // 自动滚动以触发继续加载
    if (auto.checked){
      // 交替用不同步长,偶尔上抖,帮助触发懒加载/复用窗口
      const step = STEP_BASE + (jiggleTick%5===0 ? 200 : 0);
      window.scrollBy(0, step);
      // 每 7 轮,轻微上抖 1 次再下滚
      if (jiggleTick%7===3){
        window.scrollBy(0, -120);
        await sleep(120);
        window.scrollBy(0, 240);
      }
      jiggleTick++;
    }

    await sleep(420 + Math.random()*220);
  }

  bStop.disabled=true; bExp.disabled=false;
  stat.textContent = `完成:共 ${textBlocks.length} 楼。点击“导出TXT”。`;
}


简单说明

我自己主要拿来导出长帖,方便之后搜索和总结。欢迎大家改 selector / 改参数 / 拿去魔改。

1 个赞

如果识别楼层不准怎么办?

代码最上面有一行:

const OVERRIDE_POST_SELECTOR = '';

默认会自动猜楼层选择器。

如果抓出来太碎、太少、或者抓到很多无关内容,可以手动指定选择器,比如:

const OVERRIDE_POST_SELECTOR = '.post-item';

或者:

const OVERRIDE_POST_SELECTOR = 'li.reply';

常见可试:

'article'
'.post'
'.comment'
'.reply'
'[role="article"]'
'li[data-post-id]'
'[data-comment-id]'
'.message'
'.thread-item'

不会找 selector 的话,可以右键某个楼层 → Inspect,然后看它外层 class 名。


如果没抓全怎么办?

可以调大这个参数:

const NO_NEW_LIMIT = 40;

比如改成:

const NO_NEW_LIMIT = 80;

意思是连续 80 轮没有发现新楼层才停止。长帖 / 加载慢的网站可以调大。

啥玩意

for i in range(101):
    url = f"https://www.uscardforum.com/raw/510014/?page={i}"
    content = scrape(url)
    save(content)

我的脚本有这段么,我咋没看到

冲钛

应该挺傻瓜式的,有啥问题可以在这发

猜你想找 :backhand_index_pointing_right:

Print 比较容易 让啊太limit 不如 raw 啊。而且最近类似的见了好几个了。有了 skills 和 MCP 还需要这个吗?