导出百度贴吧楼主帖子

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

// ==UserScript==
// @name         导出百度贴吧楼主帖子
// @namespace    http://tampermonkey.net/
// @version      1.1.1
// @description  将百度贴吧某帖子中楼主的所有发言保存为 HTML 文件,方便离线浏览
// @author       wiiiind
// @match        https://tieba.baidu.com/p/*
// @grant        GM_download
// @license      MIT

// ==/UserScript==


(function() {
    'use strict';

    // 添加按钮到页面
    function addButton() {
        const button = document.createElement('a');
        button.innerText = '保存楼主发言';
        button.href = 'javascript:;';
        button.className = 'btn-sub btn-small';
        button.onclick = saveTiebaPosts;
        
        // 找到按钮组区域
        const btnGroup = document.querySelector('.core_title_btns');
        if (btnGroup) {
            // 插入到按钮组的第一个位置
            btnGroup.insertBefore(button, btnGroup.firstChild);
        }
    }

    let currentPage = 1;
    let totalPages = 1;
    let posts = [];

    function fetchPosts(page) {
        const url = window.location.href.replace(/&pn=\d+/, '') + '&pn=' + page;
        return fetch(url)
            .then(response => response.text())
            .then(html => {
                const parser = new DOMParser();
                const doc = parser.parseFromString(html, 'text/html');
                const postElements = doc.querySelectorAll('.l_post');

                postElements.forEach(post => {
                    try {
                        // 获取IP属地
                        const ipSpan = post.querySelector('.post-tail-wrap span:not([class])');
                        const ip = ipSpan ? ipSpan.innerText.trim().replace(/^IP属地:/, '') : '未知IP';

                        // 获取其他信息
                        const tailInfoSpans = post.querySelectorAll('.post-tail-wrap .tail-info');
                        let deviceInfo = '未知设备';
                        let floor = '未知楼层';
                        let time = '未知时间';

                        // 遍历所有tail-info span,找到包含设备信息、楼层和时间的span
                        tailInfoSpans.forEach(span => {
                            const text = span.innerText.trim();
                            if (span.querySelector('a') && text.includes('来自')) {
                                deviceInfo = span.querySelector('a').innerText.trim();
                            } else if (text.includes('楼')) {
                                floor = text;
                            } else if (text.match(/\d{4}-\d{2}-\d{2}/)) {
                                time = text;
                            }
                        });

                        // 获取内容
                        const contentElement = post.querySelector('.d_post_content');
                        const content = contentElement ? contentElement.innerHTML.trim() : '';

                        // 修改图片获取逻辑,只获取BDE_Image类的图片
                        const images = Array.from(post.querySelectorAll('.d_post_content img.BDE_Image')).map(img => img.src || '');

                        posts.push({
                            ip,
                            deviceInfo,
                            floor,
                            time,
                            content,
                            images
                        });
                    } catch (error) {
                        console.error('处理帖子时出错:', error);
                    }
                });

                return Promise.resolve();
            })
            .catch(error => {
                console.error(`获取第${page}页数据时出错:`, error);
            });
    }

    function savePosts() {
        const title = document.querySelector('.core_title_txt').innerText.trim();
        const date = new Date().toLocaleString('zh-CN', {
            year: 'numeric',
            month: '2-digit',
            day: '2-digit',
            hour: '2-digit',
            minute: '2-digit',
            second: '2-digit',
            hour12: false
        });
        const fileName = `${title}_${date.split(' ')[0]}.html`;

        // 获取楼主信息
        const authorElement = document.querySelector('.d_name .p_author_name');
        const authorName = authorElement ? authorElement.innerText : '未知用户';
        const authorLink = authorElement ? authorElement.href : '#';
        const authorAvatar = document.querySelector('.p_author_face img');
        const avatarSrc = authorAvatar ? authorAvatar.src : '';
        const originalLink = window.location.href;

        // 在生成HTML前对posts进行排序
        posts.sort((a, b) => {
            // 从楼层文本中提取数字
            const getFloorNumber = (floor) => {
                const match = floor.match(/(\d+)/);
                return match ? parseInt(match[1], 10) : 0;
            };
            
            return getFloorNumber(a.floor) - getFloorNumber(b.floor);
        });

        let htmlContent = `
            <html>
            <head>
                <meta charset="UTF-8">
                <title>${title}</title>
                <script>
                    // 免责声明弹窗
                    window.onload = function() {
                        const disclaimer = \`1. 本帖子保存时间:${date}
2. 本脚本旨在为用户提供便利,用于个人备份公开访问的内容。请确保您在使用本脚本时遵守相关平台的用户协议及法律法规。
3. 本脚本仅限个人学习、研究或备份用途,禁止用于任何非法行为,包括但不限于:未经授权抓取、复制、传播受版权保护的内容或侵犯他人合法权益。
4. 使用本脚本可能涉及到技术风险,例如账号被限制或封禁等情况。请在使用前充分了解风险,并自行承担因使用脚本所引发的后果。
5. 本脚本的作者不对因脚本使用导致的任何直接或间接后果承担责任,包括但不限于数据丢失、账号封禁或其他法律责任。
6. 作者保留修改、更新或终止维护脚本的权利。\`;

                        window.alert = function(msg) {
                            const iframe = document.createElement('iframe');
                            iframe.style.display = 'none';
                            document.body.appendChild(iframe);
                            const alertFrame = iframe.contentWindow;
                            const result = alertFrame.alert(msg);
                            iframe.parentNode.removeChild(iframe);
                            return result;
                        };
                        
                        alert(disclaimer);
                    }

                    // 跳转楼层的函数
                    function jumpToFloor() {
                        const targetFloor = parseInt(prompt('请输入要跳转的楼层号:'));
                        if (!targetFloor) return;

                        // 获取所有楼层
                        const floors = Array.from(document.querySelectorAll('table[data-floor]'))
                            .map(table => ({
                                element: table,
                                floor: parseInt(table.getAttribute('data-floor'))
                            }))
                            .sort((a, b) => a.floor - b.floor);
                        
                        // 找到目标楼层或最近的前一个楼层
                        let targetElement = null;
                        for (let i = floors.length - 1; i >= 0; i--) {
                            if (floors[i].floor <= targetFloor) {
                                targetElement = floors[i].element;
                                break;
                            }
                        }

                        if (targetElement) {
                            targetElement.scrollIntoView({ behavior: 'smooth' });
                            // 如果不是精确匹配,显示提示
                            if (parseInt(targetElement.getAttribute('data-floor')) < targetFloor) {
                                alert('未找到该楼层,已定位到最近的前一个楼层:' + 
                                      targetElement.getAttribute('data-floor') + '楼');
                            }
                        } else {
                            // 如果连第一层都大于目标楼层,就跳转到第一层
                            if (floors.length > 0) {
                                floors[0].element.scrollIntoView({ behavior: 'smooth' });
                                alert('未找到该楼层,已定位到第一个楼层:' + 
                                      floors[0].floor + '楼');
                            }
                        }
                    }
                </script>
                <style>
                    body { 
                        margin: 20px; 
                        font-family: Arial, sans-serif;
                        max-width: 794px;  /* A4 width */
                        margin-left: auto;
                        margin-right: auto;
                    }
                    .header {
                        position: sticky;
                        top: 0;
                        background: white;
                        width: 100%;
                        z-index: 100;
                        padding: 10px 0;
                        display: flex;
                        flex-direction: column;
                        align-items: center;
                        border-bottom: 1px solid #eee;
                    }
                    .title {
                        font-size: 24px;
                        font-weight: bold;
                        margin: 10px 0;
                        text-align: center;
                    }
                    .content-wrapper {
                        margin-top: 20px;
                        width: 100%;
                        display: flex;
                        flex-direction: column;
                        align-items: center;
                    }
                    .author-info {
                        display: flex;
                        align-items: center;
                        margin-bottom: 20px;
                        gap: 10px;
                    }
                    .author-avatar {
                        width: 48px;
                        height: 48px;
                        border-radius: 50%;
                    }
                    .links {
                        margin-bottom: 20px;
                        text-align: center;
                    }
                    .links a {
                        color: #4CAF50;
                        text-decoration: none;
                        margin: 0 10px;
                    }
                    .links a:hover {
                        text-decoration: underline;
                    }
                    table { 
                        border-collapse: collapse; 
                        width: 100%;  /* Use full width of body */
                        margin-bottom: 20px;
                        position: relative; 
                    }
                    tr {
                        display: flex; 
                    }
                    td { 
                        padding: 10px; 
                        vertical-align: top; 
                    }
                    .info-cell { 
                        width: 22%; 
                        border-right: 1px solid #ddd;
                    }
                    .content-cell { 
                        width: 78%;
                        flex: 1; 
                    }
                    .floor-number { 
                        font-size: 18px; 
                        font-weight: bold; 
                        margin-bottom: 10px; 
                    }
                    .info-item { 
                        margin: 5px 0; 
                        color: #666; 
                    }
                    img { 
                        max-width: 100%; 
                        margin: 5px 0; 
                    }
                    .jump-btn {
                        position: fixed;
                        bottom: 20px;
                        right: 20px;
                        background: #4CAF50;
                        color: white;
                        border: none;
                        padding: 10px 20px;
                        border-radius: 5px;
                        cursor: pointer;
                        z-index: 1000;
                    }
                    
                    .jump-btn:hover {
                        background: #45a049;
                    }
                    
                    /* 让表格有一个data-floor属性用于跳转 */
                    table {
                        scroll-margin-top: 100px; /* 跳转时留出顶部空间 */
                    }
                    
                    /* 添加免责声明的样式 */
                    .disclaimer {
                        white-space: pre-wrap;
                        font-family: monospace;
                    }
                    .footer {
                        text-align: center;
                        padding: 20px;
                        color: #666;
                        font-size: 14px;
                        margin-top: 40px;
                        border-top: 1px solid #eee;
                    }
                    .footer a {
                        color: #4CAF50;
                        text-decoration: none;
                    }
                    .footer a:hover {
                        text-decoration: underline;
                    }
                </style>
            </head>
            <body>
                <button class="jump-btn" onclick="jumpToFloor()">跳转到指定楼层</button>
                <div class="header">
                    <div class="title">${title}</div>
                </div>
                <div class="content-wrapper">
                    <div class="author-info">
                        <img class="author-avatar" src="${avatarSrc}" alt="${authorName}">
                        <a href="${authorLink}" target="_blank">${authorName}</a>
                    </div>
                    <div class="links">
                        <a href="${originalLink}" target="_blank">查看原帖</a>
                        <a href="${authorLink}" target="_blank">作者主页</a>
                    </div>
                    <!-- 帖子内容将在这里显示 -->
        `;

        posts.forEach(post => {
            // 从content中移除所有BDE_Image图片
            const tempDiv = document.createElement('div');
            tempDiv.innerHTML = post.content;
            tempDiv.querySelectorAll('img.BDE_Image').forEach(img => img.remove());
            const contentWithoutImages = tempDiv.innerHTML;

            htmlContent += `
            <table data-floor="${post.floor.match(/(\d+)/)?.[1] || '0'}">
                <tr>
                    <td class="info-cell">
                        <div class="floor-number">${post.floor}</div>
                        <div class="info-item">IP属地: ${post.ip}</div>
                        <div class="info-item">设备: ${post.deviceInfo}</div>
                        <div class="info-item">时间: ${post.time}</div>
                    </td>
                    <td class="content-cell">
                        <div>${contentWithoutImages}</div>
                        <div>${post.images.map(src => `<img src="${src}" class="BDE_Image">`).join('')}</div>
                    </td>
                </tr>
            </table>
            `;
        });

        // 获取当前登录用户信息
        const userElement = document.querySelector('.u_menu_username a');
        const userName = userElement ? userElement.querySelector('.u_username_title').textContent : '未登录用户';
        const userLink = userElement ? userElement.href : '#';

        htmlContent += `
            <div class="footer">
                帖子由<a href="${userLink}" target="_blank">@${userName}</a>通过<a href="https://greasyfork.org/zh-CN/scripts/518200-tieba-op-posts-saver" target="_blank">此脚本</a>自动抓取生成。
                欲查看于移动设备,请<a href="javascript:void(0)" onclick="printToPDF()">另存为PDF</a>。
            </div>
            <script>
                function printToPDF() {
                    if (confirm('请选择"另存为PDF"打印机进行打印')) {
                        window.print();
                    }
                }
            </script>
        </body>
        </html>
        `;

        // 添加打印样式
        const printStyles = `
            @media print {
                .jump-btn {
                    display: none;  /* 隐藏跳转按钮 */
                }
                
                /* 确保内容完整打印 */
                .content-cell img {
                    break-inside: avoid;
                }
                
                /* 优化打印布局 */
                table {
                    break-inside: avoid;
                    page-break-inside: avoid;
                }
            }
        `;

        // 将打印样式插入到现有样式中
        htmlContent = htmlContent.replace('</style>', printStyles + '</style>');

        const blob = new Blob([htmlContent], { type: 'text/html' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = fileName;
        a.click();
        URL.revokeObjectURL(url);
    }

    function saveTiebaPosts() {
        console.log('开始保存楼主发言...');
        
        // 检查是否在只看楼主模式
        if (!window.location.href.includes('see_lz=1')) {
            alert('此功能需要在"只看楼主"模式下使用。\n\n请先点击帖子上方的"只看楼主"按钮,然后再次点击"保存楼主发言"。');
            // 找到"只看楼主"按钮并高亮显示
            const lzOnlyBtn = document.querySelector('#lzonly_cntn');
            if (lzOnlyBtn) {
                // 保存原始样式
                const originalBackground = lzOnlyBtn.style.background;
                const originalTransition = lzOnlyBtn.style.transition;
                
                // 添加闪烁效果
                lzOnlyBtn.style.transition = 'background 0.5s';
                lzOnlyBtn.style.background = '#ffd700';
                
                // 1秒后恢复原样
                setTimeout(() => {
                    lzOnlyBtn.style.background = originalBackground;
                    lzOnlyBtn.style.transition = originalTransition;
                }, 1000);
            }
            return;
        }

        // 清空之前的帖子数据
        posts = [];
        currentPage = 1;
        
        // 获取总页数
        const lastPageLink = document.querySelector('.l_pager a[href*="pn="]:last-child');
        if (lastPageLink) {
            const match = lastPageLink.href.match(/pn=(\d+)/);
            if (match) {
                totalPages = parseInt(match[1], 10);
            }
        }

        console.log(`总页数: ${totalPages}`);

        // 创建一个加载提示
        const loadingDiv = document.createElement('div');
        loadingDiv.style.position = 'fixed';
        loadingDiv.style.top = '50%';
        loadingDiv.style.left = '50%';
        loadingDiv.style.transform = 'translate(-50%, -50%)';
        loadingDiv.style.padding = '20px';
        loadingDiv.style.background = 'rgba(0,0,0,0.8)';
        loadingDiv.style.color = 'white';
        loadingDiv.style.borderRadius = '5px';
        loadingDiv.style.zIndex = '10000';
        document.body.appendChild(loadingDiv);

        // 使Promise.all和分批处理来获取所有页面
        const batchSize = 5; // 每批处理5个页面
        const batches = [];
        
        for (let i = 1; i <= totalPages; i += batchSize) {
            const batch = [];
            for (let j = i; j < Math.min(i + batchSize, totalPages + 1); j++) {
                batch.push(fetchPosts(j));
            }
            batches.push(batch);
        }

        // 按批次处理所有页面
        let processedPages = 0;
        const processBatch = async (batchIndex) => {
            if (batchIndex >= batches.length) {
                // 所有批次处理完成,保存文件
                loadingDiv.remove();
                savePosts();
                return;
            }

            await Promise.all(batches[batchIndex]);
            processedPages += batches[batchIndex].length;
            loadingDiv.textContent = `正在获取帖子内容... ${Math.min(processedPages, totalPages)}/${totalPages}`;
            
            // 延迟处理下一批次,避免请求过快
            setTimeout(() => processBatch(batchIndex + 1), 1000);
        };

        loadingDiv.textContent = '正在获取帖子内容... 0/' + totalPages;
        processBatch(0);
    }

    // 初始化
    addButton();
})();