导出百度贴吧楼主帖子

将百度贴吧某帖子中楼主的所有发言保存为 HTML 文件,方便离线浏览

  1. // ==UserScript==
  2. // @name 导出百度贴吧楼主帖子
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.1.1
  5. // @description 将百度贴吧某帖子中楼主的所有发言保存为 HTML 文件,方便离线浏览
  6. // @author wiiiind
  7. // @match https://tieba.baidu.com/p/*
  8. // @grant GM_download
  9. // @license MIT
  10.  
  11. // ==/UserScript==
  12.  
  13.  
  14. (function() {
  15. 'use strict';
  16.  
  17. // 添加按钮到页面
  18. function addButton() {
  19. const button = document.createElement('a');
  20. button.innerText = '保存楼主发言';
  21. button.href = 'javascript:;';
  22. button.className = 'btn-sub btn-small';
  23. button.onclick = saveTiebaPosts;
  24. // 找到按钮组区域
  25. const btnGroup = document.querySelector('.core_title_btns');
  26. if (btnGroup) {
  27. // 插入到按钮组的第一个位置
  28. btnGroup.insertBefore(button, btnGroup.firstChild);
  29. }
  30. }
  31.  
  32. let currentPage = 1;
  33. let totalPages = 1;
  34. let posts = [];
  35.  
  36. function fetchPosts(page) {
  37. const url = window.location.href.replace(/&pn=\d+/, '') + '&pn=' + page;
  38. return fetch(url)
  39. .then(response => response.text())
  40. .then(html => {
  41. const parser = new DOMParser();
  42. const doc = parser.parseFromString(html, 'text/html');
  43. const postElements = doc.querySelectorAll('.l_post');
  44.  
  45. postElements.forEach(post => {
  46. try {
  47. // 获取IP属地
  48. const ipSpan = post.querySelector('.post-tail-wrap span:not([class])');
  49. const ip = ipSpan ? ipSpan.innerText.trim().replace(/^IP属地:/, '') : '未知IP';
  50.  
  51. // 获取其他信息
  52. const tailInfoSpans = post.querySelectorAll('.post-tail-wrap .tail-info');
  53. let deviceInfo = '未知设备';
  54. let floor = '未知楼层';
  55. let time = '未知时间';
  56.  
  57. // 遍历所有tail-info span,找到包含设备信息、楼层和时间的span
  58. tailInfoSpans.forEach(span => {
  59. const text = span.innerText.trim();
  60. if (span.querySelector('a') && text.includes('来自')) {
  61. deviceInfo = span.querySelector('a').innerText.trim();
  62. } else if (text.includes('楼')) {
  63. floor = text;
  64. } else if (text.match(/\d{4}-\d{2}-\d{2}/)) {
  65. time = text;
  66. }
  67. });
  68.  
  69. // 获取内容
  70. const contentElement = post.querySelector('.d_post_content');
  71. const content = contentElement ? contentElement.innerHTML.trim() : '';
  72.  
  73. // 修改图片获取逻辑,只获取BDE_Image类的图片
  74. const images = Array.from(post.querySelectorAll('.d_post_content img.BDE_Image')).map(img => img.src || '');
  75.  
  76. posts.push({
  77. ip,
  78. deviceInfo,
  79. floor,
  80. time,
  81. content,
  82. images
  83. });
  84. } catch (error) {
  85. console.error('处理帖子时出错:', error);
  86. }
  87. });
  88.  
  89. return Promise.resolve();
  90. })
  91. .catch(error => {
  92. console.error(`获取第${page}页数据时出错:`, error);
  93. });
  94. }
  95.  
  96. function savePosts() {
  97. const title = document.querySelector('.core_title_txt').innerText.trim();
  98. const date = new Date().toLocaleString('zh-CN', {
  99. year: 'numeric',
  100. month: '2-digit',
  101. day: '2-digit',
  102. hour: '2-digit',
  103. minute: '2-digit',
  104. second: '2-digit',
  105. hour12: false
  106. });
  107. const fileName = `${title}_${date.split(' ')[0]}.html`;
  108.  
  109. // 获取楼主信息
  110. const authorElement = document.querySelector('.d_name .p_author_name');
  111. const authorName = authorElement ? authorElement.innerText : '未知用户';
  112. const authorLink = authorElement ? authorElement.href : '#';
  113. const authorAvatar = document.querySelector('.p_author_face img');
  114. const avatarSrc = authorAvatar ? authorAvatar.src : '';
  115. const originalLink = window.location.href;
  116.  
  117. // 在生成HTML前对posts进行排序
  118. posts.sort((a, b) => {
  119. // 从楼层文本中提取数字
  120. const getFloorNumber = (floor) => {
  121. const match = floor.match(/(\d+)/);
  122. return match ? parseInt(match[1], 10) : 0;
  123. };
  124. return getFloorNumber(a.floor) - getFloorNumber(b.floor);
  125. });
  126.  
  127. let htmlContent = `
  128. <html>
  129. <head>
  130. <meta charset="UTF-8">
  131. <title>${title}</title>
  132. <script>
  133. // 免责声明弹窗
  134. window.onload = function() {
  135. const disclaimer = \`1. 本帖子保存时间:${date}
  136. 2. 本脚本旨在为用户提供便利,用于个人备份公开访问的内容。请确保您在使用本脚本时遵守相关平台的用户协议及法律法规。
  137. 3. 本脚本仅限个人学习、研究或备份用途,禁止用于任何非法行为,包括但不限于:未经授权抓取、复制、传播受版权保护的内容或侵犯他人合法权益。
  138. 4. 使用本脚本可能涉及到技术风险,例如账号被限制或封禁等情况。请在使用前充分了解风险,并自行承担因使用脚本所引发的后果。
  139. 5. 本脚本的作者不对因脚本使用导致的任何直接或间接后果承担责任,包括但不限于数据丢失、账号封禁或其他法律责任。
  140. 6. 作者保留修改、更新或终止维护脚本的权利。\`;
  141.  
  142. window.alert = function(msg) {
  143. const iframe = document.createElement('iframe');
  144. iframe.style.display = 'none';
  145. document.body.appendChild(iframe);
  146. const alertFrame = iframe.contentWindow;
  147. const result = alertFrame.alert(msg);
  148. iframe.parentNode.removeChild(iframe);
  149. return result;
  150. };
  151. alert(disclaimer);
  152. }
  153.  
  154. // 跳转楼层的函数
  155. function jumpToFloor() {
  156. const targetFloor = parseInt(prompt('请输入要跳转的楼层号:'));
  157. if (!targetFloor) return;
  158.  
  159. // 获取所有楼层
  160. const floors = Array.from(document.querySelectorAll('table[data-floor]'))
  161. .map(table => ({
  162. element: table,
  163. floor: parseInt(table.getAttribute('data-floor'))
  164. }))
  165. .sort((a, b) => a.floor - b.floor);
  166. // 找到目标楼层或最近的前一个楼层
  167. let targetElement = null;
  168. for (let i = floors.length - 1; i >= 0; i--) {
  169. if (floors[i].floor <= targetFloor) {
  170. targetElement = floors[i].element;
  171. break;
  172. }
  173. }
  174.  
  175. if (targetElement) {
  176. targetElement.scrollIntoView({ behavior: 'smooth' });
  177. // 如果不是精确匹配,显示提示
  178. if (parseInt(targetElement.getAttribute('data-floor')) < targetFloor) {
  179. alert('未找到该楼层,已定位到最近的前一个楼层:' +
  180. targetElement.getAttribute('data-floor') + '楼');
  181. }
  182. } else {
  183. // 如果连第一层都大于目标楼层,就跳转到第一层
  184. if (floors.length > 0) {
  185. floors[0].element.scrollIntoView({ behavior: 'smooth' });
  186. alert('未找到该楼层,已定位到第一个楼层:' +
  187. floors[0].floor + '楼');
  188. }
  189. }
  190. }
  191. </script>
  192. <style>
  193. body {
  194. margin: 20px;
  195. font-family: Arial, sans-serif;
  196. max-width: 794px; /* A4 width */
  197. margin-left: auto;
  198. margin-right: auto;
  199. }
  200. .header {
  201. position: sticky;
  202. top: 0;
  203. background: white;
  204. width: 100%;
  205. z-index: 100;
  206. padding: 10px 0;
  207. display: flex;
  208. flex-direction: column;
  209. align-items: center;
  210. border-bottom: 1px solid #eee;
  211. }
  212. .title {
  213. font-size: 24px;
  214. font-weight: bold;
  215. margin: 10px 0;
  216. text-align: center;
  217. }
  218. .content-wrapper {
  219. margin-top: 20px;
  220. width: 100%;
  221. display: flex;
  222. flex-direction: column;
  223. align-items: center;
  224. }
  225. .author-info {
  226. display: flex;
  227. align-items: center;
  228. margin-bottom: 20px;
  229. gap: 10px;
  230. }
  231. .author-avatar {
  232. width: 48px;
  233. height: 48px;
  234. border-radius: 50%;
  235. }
  236. .links {
  237. margin-bottom: 20px;
  238. text-align: center;
  239. }
  240. .links a {
  241. color: #4CAF50;
  242. text-decoration: none;
  243. margin: 0 10px;
  244. }
  245. .links a:hover {
  246. text-decoration: underline;
  247. }
  248. table {
  249. border-collapse: collapse;
  250. width: 100%; /* Use full width of body */
  251. margin-bottom: 20px;
  252. position: relative;
  253. }
  254. tr {
  255. display: flex;
  256. }
  257. td {
  258. padding: 10px;
  259. vertical-align: top;
  260. }
  261. .info-cell {
  262. width: 22%;
  263. border-right: 1px solid #ddd;
  264. }
  265. .content-cell {
  266. width: 78%;
  267. flex: 1;
  268. }
  269. .floor-number {
  270. font-size: 18px;
  271. font-weight: bold;
  272. margin-bottom: 10px;
  273. }
  274. .info-item {
  275. margin: 5px 0;
  276. color: #666;
  277. }
  278. img {
  279. max-width: 100%;
  280. margin: 5px 0;
  281. }
  282. .jump-btn {
  283. position: fixed;
  284. bottom: 20px;
  285. right: 20px;
  286. background: #4CAF50;
  287. color: white;
  288. border: none;
  289. padding: 10px 20px;
  290. border-radius: 5px;
  291. cursor: pointer;
  292. z-index: 1000;
  293. }
  294. .jump-btn:hover {
  295. background: #45a049;
  296. }
  297. /* 让表格有一个data-floor属性用于跳转 */
  298. table {
  299. scroll-margin-top: 100px; /* 跳转时留出顶部空间 */
  300. }
  301. /* 添加免责声明的样式 */
  302. .disclaimer {
  303. white-space: pre-wrap;
  304. font-family: monospace;
  305. }
  306. .footer {
  307. text-align: center;
  308. padding: 20px;
  309. color: #666;
  310. font-size: 14px;
  311. margin-top: 40px;
  312. border-top: 1px solid #eee;
  313. }
  314. .footer a {
  315. color: #4CAF50;
  316. text-decoration: none;
  317. }
  318. .footer a:hover {
  319. text-decoration: underline;
  320. }
  321. </style>
  322. </head>
  323. <body>
  324. <button class="jump-btn" onclick="jumpToFloor()">跳转到指定楼层</button>
  325. <div class="header">
  326. <div class="title">${title}</div>
  327. </div>
  328. <div class="content-wrapper">
  329. <div class="author-info">
  330. <img class="author-avatar" src="${avatarSrc}" alt="${authorName}">
  331. <a href="${authorLink}" target="_blank">${authorName}</a>
  332. </div>
  333. <div class="links">
  334. <a href="${originalLink}" target="_blank">查看原帖</a>
  335. <a href="${authorLink}" target="_blank">作者主页</a>
  336. </div>
  337. <!-- 帖子内容将在这里显示 -->
  338. `;
  339.  
  340. posts.forEach(post => {
  341. // 从content中移除所有BDE_Image图片
  342. const tempDiv = document.createElement('div');
  343. tempDiv.innerHTML = post.content;
  344. tempDiv.querySelectorAll('img.BDE_Image').forEach(img => img.remove());
  345. const contentWithoutImages = tempDiv.innerHTML;
  346.  
  347. htmlContent += `
  348. <table data-floor="${post.floor.match(/(\d+)/)?.[1] || '0'}">
  349. <tr>
  350. <td class="info-cell">
  351. <div class="floor-number">${post.floor}</div>
  352. <div class="info-item">IP属地: ${post.ip}</div>
  353. <div class="info-item">设备: ${post.deviceInfo}</div>
  354. <div class="info-item">时间: ${post.time}</div>
  355. </td>
  356. <td class="content-cell">
  357. <div>${contentWithoutImages}</div>
  358. <div>${post.images.map(src => `<img src="${src}" class="BDE_Image">`).join('')}</div>
  359. </td>
  360. </tr>
  361. </table>
  362. `;
  363. });
  364.  
  365. // 获取当前登录(不可用)用户信息
  366. const userElement = document.querySelector('.u_menu_username a');
  367. const userName = userElement ? userElement.querySelector('.u_username_title').textContent : '未登录(不可用)用户';
  368. const userLink = userElement ? userElement.href : '#';
  369.  
  370. htmlContent += `
  371. <div class="footer">
  372. 帖子由<a href="${userLink}" target="_blank">@${userName}</a>通过<a href="https://gf.qytechs.cn/zh-CN/scripts/518200-tieba-op-posts-saver" target="_blank">此脚本</a>自动抓取生成。
  373. 欲查看于移动设备,请<a href="javascript:void(0)" onclick="printToPDF()">另存为PDF</a>。
  374. </div>
  375. <script>
  376. function printToPDF() {
  377. if (confirm('请选择"另存为PDF"打印机进行打印')) {
  378. window.print();
  379. }
  380. }
  381. </script>
  382. </body>
  383. </html>
  384. `;
  385.  
  386. // 添加打印样式
  387. const printStyles = `
  388. @media print {
  389. .jump-btn {
  390. display: none; /* 隐藏跳转按钮 */
  391. }
  392. /* 确保内容完整打印 */
  393. .content-cell img {
  394. break-inside: avoid;
  395. }
  396. /* 优化打印布局 */
  397. table {
  398. break-inside: avoid;
  399. page-break-inside: avoid;
  400. }
  401. }
  402. `;
  403.  
  404. // 将打印样式插入到现有样式中
  405. htmlContent = htmlContent.replace('</style>', printStyles + '</style>');
  406.  
  407. const blob = new Blob([htmlContent], { type: 'text/html' });
  408. const url = URL.createObjectURL(blob);
  409. const a = document.createElement('a');
  410. a.href = url;
  411. a.download = fileName;
  412. a.click();
  413. URL.revokeObjectURL(url);
  414. }
  415.  
  416. function saveTiebaPosts() {
  417. console.log('开始保存楼主发言...');
  418. // 检查是否在只看楼主模式
  419. if (!window.location.href.includes('see_lz=1')) {
  420. alert('此功能需要在"只看楼主"模式下使用。\n\n请先点击帖子上方的"只看楼主"按钮,然后再次点击"保存楼主发言"。');
  421. // 找到"只看楼主"按钮并高亮显示
  422. const lzOnlyBtn = document.querySelector('#lzonly_cntn');
  423. if (lzOnlyBtn) {
  424. // 保存原始样式
  425. const originalBackground = lzOnlyBtn.style.background;
  426. const originalTransition = lzOnlyBtn.style.transition;
  427. // 添加闪烁效果
  428. lzOnlyBtn.style.transition = 'background 0.5s';
  429. lzOnlyBtn.style.background = '#ffd700';
  430. // 1秒后恢复原样
  431. setTimeout(() => {
  432. lzOnlyBtn.style.background = originalBackground;
  433. lzOnlyBtn.style.transition = originalTransition;
  434. }, 1000);
  435. }
  436. return;
  437. }
  438.  
  439. // 清空之前的帖子数据
  440. posts = [];
  441. currentPage = 1;
  442. // 获取总页数
  443. const lastPageLink = document.querySelector('.l_pager a[href*="pn="]:last-child');
  444. if (lastPageLink) {
  445. const match = lastPageLink.href.match(/pn=(\d+)/);
  446. if (match) {
  447. totalPages = parseInt(match[1], 10);
  448. }
  449. }
  450.  
  451. console.log(`总页数: ${totalPages}`);
  452.  
  453. // 创建一个加载提示
  454. const loadingDiv = document.createElement('div');
  455. loadingDiv.style.position = 'fixed';
  456. loadingDiv.style.top = '50%';
  457. loadingDiv.style.left = '50%';
  458. loadingDiv.style.transform = 'translate(-50%, -50%)';
  459. loadingDiv.style.padding = '20px';
  460. loadingDiv.style.background = 'rgba(0,0,0,0.8)';
  461. loadingDiv.style.color = 'white';
  462. loadingDiv.style.borderRadius = '5px';
  463. loadingDiv.style.zIndex = '10000';
  464. document.body.appendChild(loadingDiv);
  465.  
  466. // 使Promise.all和分批处理来获取所有页面
  467. const batchSize = 5; // 每批处理5个页面
  468. const batches = [];
  469. for (let i = 1; i <= totalPages; i += batchSize) {
  470. const batch = [];
  471. for (let j = i; j < Math.min(i + batchSize, totalPages + 1); j++) {
  472. batch.push(fetchPosts(j));
  473. }
  474. batches.push(batch);
  475. }
  476.  
  477. // 按批次处理所有页面
  478. let processedPages = 0;
  479. const processBatch = async (batchIndex) => {
  480. if (batchIndex >= batches.length) {
  481. // 所有批次处理完成,保存文件
  482. loadingDiv.remove();
  483. savePosts();
  484. return;
  485. }
  486.  
  487. await Promise.all(batches[batchIndex]);
  488. processedPages += batches[batchIndex].length;
  489. loadingDiv.textContent = `正在获取帖子内容... ${Math.min(processedPages, totalPages)}/${totalPages}`;
  490. // 延迟处理下一批次,避免请求过快
  491. setTimeout(() => processBatch(batchIndex + 1), 1000);
  492. };
  493.  
  494. loadingDiv.textContent = '正在获取帖子内容... 0/' + totalPages;
  495. processBatch(0);
  496. }
  497.  
  498. // 初始化
  499. addButton();
  500. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址