zhihu_salt_analysis

auto get salt data based CreatorRangePicker-PopoverContent

// ==UserScript==
// @name         zhihu_salt_analysis
// @namespace    http://tampermonkey.net/
// @version      2025-09-28_2
// @description  auto get salt data based CreatorRangePicker-PopoverContent
// @author       Archimon@zhihu
// @match        https://www.zhihu.com/creator/knowledge-income
// @icon         https://picx.zhimg.com/v2-1de07498cdef102d69ed02e275c51ba9_xll.jpg?source=32738c0c&needBackground=1
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/echarts.min.js
// @grant        none
// @license MIT
// ==/UserScript==

function formatDate() {
    const date = new Date();
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, '0');
    const yesterday = String(date.getDate()-1).padStart(2, '0');
    return `${yesterday}`;
}

// 等待元素加载的辅助函数
function waitForElement(selector, timeout = 5000) {
    return new Promise((resolve, reject) => {
        const element = document.querySelector(selector);
        if (element) {
            resolve(element);
            return;
        }

        const observer = new MutationObserver(() => {
            const element = document.querySelector(selector);
            if (element) {
                observer.disconnect();
                resolve(element);
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        setTimeout(() => {
            observer.disconnect();
            reject(new Error(`Element ${selector} not found within ${timeout}ms`));
        }, timeout);
    });
}

// 等待指定时间的辅助函数
function wait(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}

// 检测元素消失的辅助函数
function waitForElementDisappear(selector, timeout = 10000) {
    return new Promise((resolve, reject) => {
        const element = document.querySelector(selector);
        if (!element) {
            resolve();
            return;
        }

        const observer = new MutationObserver(() => {
            const currentElement = document.querySelector(selector);
            if (!currentElement) {
                observer.disconnect();
                resolve();
            }
        });

        observer.observe(document.body, {
            childList: true,
            subtree: true
        });

        setTimeout(() => {
            observer.disconnect();
            reject(new Error(`Element ${selector} still exists after ${timeout}ms`));
        }, timeout);
    });
}

// 监控日期选择器变化的函数(支持多次选择)
function monitorDatePickerChanges(callback) {
    console.log('开始监控日期选择器变化...');

    const datePickerSelector = 'div[class*="CreatorRangePicker-PopoverContent"]';
    let isMonitoring = true;

    const observer = new MutationObserver(() => {
        if (!isMonitoring) return;

        const datePicker = document.querySelector(datePickerSelector);

        if (datePicker) {
            console.log('日期选择器出现,开始监控消失...');

            // 监控日期选择器消失
            const disappearObserver = new MutationObserver(() => {
                const currentPicker = document.querySelector(datePickerSelector);
                if (!currentPicker) {
                    disappearObserver.disconnect();
                    console.log('日期选择器消失,执行回调...');

                    // 等待页面稳定后执行回调
                    setTimeout(async () => {
                        try {
                            await waitForPageLoad();
                            if (callback && typeof callback === 'function') {
                                await callback();
                            }
                        } catch (error) {
                            console.warn('回调执行失败:', error.message);
                        }
                    }, 500);
                }
            });

            disappearObserver.observe(document.body, {
                childList: true,
                subtree: true
            });

            // 设置超时,避免无限监控
            setTimeout(() => {
                disappearObserver.disconnect();
            }, 15000);
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

    // 返回停止监控的函数
    return () => {
        isMonitoring = false;
        observer.disconnect();
        console.log('停止监控日期选择器变化');
    };
}

// 检测页面是否加载完成的函数
function waitForPageLoad(timeout = 5000) {
    return new Promise((resolve, reject) => {
        // 检查页面是否已经加载完成
        if (document.readyState === 'complete') {
            resolve();
            return;
        }

        // 监听页面加载完成事件
        window.addEventListener('load', () => {
            resolve();
        });

        // 设置超时
        setTimeout(() => {
            reject(new Error('Page load timeout'));
        }, timeout);
    });
}

async function set_date(date, callback = null){
    let next_button = null;
    const buttons = document.querySelectorAll(`
            input[type="button"],
            button[type="button"]
        `);

    for (const button of buttons) {
        if (button.className.includes("CreatorRangePicker-Button")){
            button.click();
            await wait(100);

            // 等待日期选择器弹出
            const CreatorRangePicker = await waitForElement('div[class*="CreatorRangePicker-PopoverContent"]');

            // CreatorRangePicker[0].childNodes[0] 是弹出的日期选择窗口的左边部分 CreatorRangePicker[0].childNodes[1]是右边部分
            const days_all = CreatorRangePicker.childNodes[1].childNodes[1].childNodes[0].childNodes[1].childNodes;

            for (const row of days_all) {
                const days_row = row.childNodes;
                for (const day of days_row) {
                    if (day.textContent.trim().includes(`${date}`)){
                        // 双击昨天
                        day.click();
                        await wait(120);
                        day.click();

                        // 日期选择后,等待选择器消失
                        console.log('日期已选择,等待日期选择器消失...');
                        try {
                            await waitForElementDisappear('div[class*="CreatorRangePicker-PopoverContent"]');
                            console.log('日期选择器已消失,等待页面加载...');

                            // 等待页面加载完成
                            await waitForPageLoad();
                            console.log('页面加载完成');

                            // 如果有回调函数,执行回调
                            if (callback && typeof callback === 'function') {
                                console.log('执行回调函数...');
                                await callback();
                            }
                        } catch (error) {
                            console.warn('日期选择器消失检测超时:', error.message);
                        }

                        break; // 找到日期后跳出循环
                    }
                }
            }
        } else if (button.className.includes("CreatorPagination-nextButton")){
            next_button = button;
        }
    }

    const benqishouyi = document.querySelectorAll('th[class*=CreatorTable-tableHead--hasSorter]');
    for (const th of benqishouyi) {
        await wait(240);
        th.click();
    }

    return next_button;
}

function parseRow(row) {
    const cells = row.querySelectorAll('td, th');
    return Array.from(cells)
        .map(cell => {
            const text = cell.textContent.trim();

            // 获取cell元素中的链接a href
            let linkHref = null;
            const linkElement = cell.querySelector('a');
            if (linkElement && linkElement.href) {
                linkHref = linkElement.href;
            }

            return {
                text: text === '' ? null : text,
                linkHref: linkHref,
                rowspan: cell.rowSpan || 1,
                colspan: cell.colSpan || 1
            };
        })
        .filter(cell => cell.text !== null && cell.text !== ''); // 过滤掉text为null或空字符串的单元格
}

function prepareChartData(rows) {
    const fullData = [];
    rows.forEach((row, index) => {
        // 从链接中提取回答ID
        let ans_url = null;
        if (row[0].linkHref) {
            const match = row[0].linkHref.match(/answer\/([^\/\?]+)/);
            if (match) {
                ans_url = match[1];
            }
        }

        fullData.push({
            ans_names:row[0].text,
            ans_types:row[1].text,
            ans_times:row[2].text,
            ans_local_reads:parseInt(row[3].text.replace(/,/g, '')) || 0,
            ans_local_salts:parseInt(row[4].text.replace(/,/g, '')) || 0,
            ans_all_reads:parseInt(row[5].text.replace(/,/g, '')) || 0,
            ans_all_salts:parseInt(row[6].text.replace(/,/g, '')) || 0,
            ans_url:ans_url
        });
    });
    return fullData;
    }

// 创建饼图数据
function preparePieChartData(fullData) {
    const salt_all_yesterday = fullData.reduce((sum, item) => sum + item.ans_local_salts, 0);
    const read_all_yesterday = fullData.reduce((sum, item) => sum + item.ans_local_reads, 0);

    // 筛选盐粒占比超过1%的元素
    const significantData = fullData.filter(item => {
        const percentage = (item.ans_local_salts / salt_all_yesterday) * 100;
        return percentage >= 1;
    });

    // 取盐粒占比超过10%的元素,或前7个,取最大值
    const maxCount = Math.max(significantData.length, 7);
    const topData = fullData.slice(0, maxCount);
    const otherData = fullData.slice(maxCount);

    // 计算其他数据的盐粒和阅读数
    const otherSalt = otherData.reduce((sum, item) => sum + item.ans_local_salts, 0);
    const otherRead = otherData.reduce((sum, item) => sum + item.ans_local_reads, 0);
    const otherCount = otherData.length;

    const pieData = topData.map((item, index) => ({
        name: item.ans_names.length > 15 ? item.ans_names.substring(0, 15) + '...' : item.ans_names,
        value: item.ans_local_salts,
        percentage: ((item.ans_local_salts / salt_all_yesterday) * 100).toFixed(2),
        salt_read_ratio: (item.ans_local_salts / item.ans_local_reads).toFixed(2),
        read_count: item.ans_local_reads
    }));

    // 如果有其他数据,添加"其他"项
    if (otherSalt > 0) {
        pieData.push({
            name: `其他 (${otherCount}个回答)`,
            value: otherSalt,
            percentage: ((otherSalt / salt_all_yesterday) * 100).toFixed(2),
            salt_read_ratio: (otherSalt / otherRead).toFixed(2),
            answer_count: otherCount,
            read_count: otherRead
        });
    }

    // 添加总阅读量信息
    pieData.total_salt = salt_all_yesterday;
    pieData.total_read = read_all_yesterday;
    pieData.total_salt_read_ratio = (salt_all_yesterday / read_all_yesterday).toFixed(2);

    return pieData;
}

// 获取日期选择器的时间信息
function getDateRangeText() {
    // 查找日期选择器按钮,使用类名选择器,忽略动态生成的类名部分
    const datePickerButton = document.querySelector('button[class*="CreatorRangePicker-Button"]');
    if (datePickerButton) {
        // 获取按钮的文本内容,去除SVG图标等非文本内容
        const textContent = datePickerButton.textContent.trim();
        // 提取日期范围信息
        const dateMatch = textContent.match(/(\d{4}\/\d{2}\/\d{2})[^]*?(\d{4}\/\d{2}\/\d{2})/);
        if (dateMatch) {
            return `${dateMatch[1]} - ${dateMatch[2]}`;
        }
    }
    return '未知日期';
}

// 创建ECharts饼图
function createPieChart(pieData, fullDataLength, containerId = 'salt-pie-chart') {
    // 创建容器
    const chartContainer = document.createElement('div');
    chartContainer.id = containerId;
    chartContainer.style.cssText = `
        width: 1016px;
        height: 400px;
        margin: 20px auto;
        border: 1px solid #e8e8e8;
        border-radius: 8px;
        padding: 20px;
        background: #fff;
        box-shadow: 0 2px 8px rgba(0,0,0,0.1);
    `;

    // 初始化图表
    const chart = echarts.init(chartContainer);

    // 获取日期范围
    const dateRange = getDateRangeText();

    // 使用从preparePieChartData传递过来的总盐粒量和总盐粒阅读比
    const totalSalt = pieData.total_salt || pieData.reduce((sum, item) => sum + item.value, 0);
    const totalRead = pieData.total_read || pieData.reduce((sum, item) => sum + (item.read_count || 0), 0);
    const totalSaltReadRatio = pieData.total_salt_read_ratio || (totalRead > 0 ? (totalSalt / totalRead).toFixed(2) : '0.00');

    // 配置项
    const option = {
        title: {
            text: `盐粒来源分布图\n${dateRange}\n共${fullDataLength}个回答 | 总盐粒: ${totalSalt} | 总盐粒阅读比: ${totalSaltReadRatio}`,
            left: 'center',
            textStyle: {
                fontSize: 16,
                fontWeight: 'bold'
            }
        },
        tooltip: {
            trigger: 'item',
            formatter: function(params) {
                return `${params.name}<br/>盐粒: ${params.value}<br/>占比: ${params.data.percentage}%<br/>盐粒阅读比: ${params.data.salt_read_ratio}`;
            }
        },
        legend: {
            orient: 'vertical',
            right: 10,
            top: 'center',
            type: 'scroll',
            pageTextStyle: {
                color: '#666'
            }
        },
        series: [
            {
                name: '盐粒分布',
                type: 'pie',
                radius: ['40%', '70%'],
                center: ['40%', '50%'],
                avoidLabelOverlap: false,
                itemStyle: {
                    borderColor: '#fff',
                    borderWidth: 2
                },
                label: {
                    show: false,
                    position: 'center'
                },
                emphasis: {
                    label: {
                        show: true,
                        fontSize: 18,
                        fontWeight: 'bold',
                        formatter: '{b}\n{c}盐粒\n({d}%)'
                    }
                },
                labelLine: {
                    show: false
                },
                data: pieData.map(item => ({
                    name: item.name,
                    value: item.value,
                    percentage: item.percentage,
                    salt_read_ratio: item.salt_read_ratio
                }))
            }
        ],
        color: [
            '#5470c6', '#91cc75', '#fac858', '#ee6666',
            '#73c0de', '#3ba272', '#fc8452', '#9a60b4',
            '#ea7ccc'
        ]
    };

    // 设置配置项并渲染
    chart.setOption(option);

    // 响应窗口大小变化
    window.addEventListener('resize', function() {
        chart.resize();
    });

    return chartContainer;
}

function createDataTable(fullData) {
    let tableHtml = `<div style="position:relative;left:20px;top:30px">
    <table class="CreatorTable-table ToolsRecommendList-Table" cellspacing="0" cellpadding="0">
        <thead>
            <tr class="CreatorTable-tableRow">
                <th class="CreatorTable-tableHead css-0" width="140" data-tooltip-classname="CreatorTable-Tooltip" data-tooltip-position="bottom" style="text-align: center;">内容</th>
                <th class="CreatorTable-tableHead css-0" width="140" data-tooltip-classname="CreatorTable-Tooltip" data-tooltip-position="bottom" style="text-align: center;">本期盐粒</th>
                <th class="CreatorTable-tableHead css-0" width="140" data-tooltip-classname="CreatorTable-Tooltip" data-tooltip-position="bottom" style="text-align: center;">收益占比</th>
                <th class="CreatorTable-tableHead css-0" width="140" data-tooltip-classname="CreatorTable-Tooltip" data-tooltip-position="bottom" style="text-align: center;">盐粒阅读比</th>
            </tr>
        </thead>
    <tbody>
        `;
    const salt_all_range = fullData.reduce((sum, item) => sum + item.ans_local_salts, 0);
    const read_all_yesterday = fullData.reduce((sum, item) => sum + item.ans_local_reads, 0);
    tableHtml += `<tr>
                    <td class="CreatorTable-tableData css-0" style="text-align: center;">${fullData.length}个回答</td>
                    <td class="CreatorTable-tableData css-0" style="text-align: center;">${salt_all_range}</td>
                    <td class="CreatorTable-tableData css-0" style="text-align: center;">100%</td>
                    <td class="CreatorTable-tableData css-0" style="text-align: center;">${(salt_all_range/read_all_yesterday).toFixed(2)}</td>
                </tr>
            `;
    fullData.forEach((item,index) => {
        const percentage = ((item.ans_local_salts / salt_all_range) * 100).toFixed(2);
        tableHtml += `<tr>
                    <td class="CreatorTable-tableData css-0" style="text-align: center;"><div class="css-13zgqlo"><a href="https://www.zhihu.com/answer/${item.ans_url}" target="_blank" rel="noopener noreferrer" style="font-size: 14px; color: rgb(25, 27, 31); font-weight: 500; display: -webkit-box; overflow: hidden; text-overflow: ellipsis; -webkit-line-clamp: 3; -webkit-box-orient: vertical; white-space: pre-wrap;">${item.ans_names}</a></div></td>
                    <td class="CreatorTable-tableData css-0" style="text-align: center;">${item.ans_local_salts}</td>
                    <td class="CreatorTable-tableData css-0" style="text-align: center;">${percentage}%</td>
                    <td class="CreatorTable-tableData css-0" style="text-align: center;">${(item.ans_local_salts/item.ans_local_reads).toFixed(2)}</td>
                </tr>
            `;
        });

        tableHtml += `
                </tbody>
            </table></div>
        `;

        return tableHtml;
    }

async function get_data_draw(next_page_button){
    const table_data = {
        headers: [],
        rows: [],
        };

    const page_num = document.querySelector('div[class*="CreatorPagination-pageNumber"]');
    const end_page = parseInt(page_num.textContent.split('/')[1].trim());

    for (let i = 1; i <= end_page; i++) {
        // 等待页面稳定
        await wait(500);

        let table = document.querySelector('table[class*=ToolsRecommendList-Table]');
        if (!table) {
            console.warn(`第 ${i} 页表格未找到,等待重试...`);
            await wait(1000);
            table = document.querySelector('table[class*=ToolsRecommendList-Table]');
        }

        if (table) {
            let thead = table.childNodes[0];
            table_data.headers = parseRow(thead);
            let data_rows = table.childNodes[1].querySelectorAll('tr');
            let parsed_rows = Array.from(data_rows).map(row => parseRow(row));
            table_data.rows = Array.from(table_data.rows).concat(parsed_rows).filter(row => row.length !== 0);
            // console.log(table_data.rows);
        }

        if (i < end_page && next_page_button) {
            next_page_button.click();
            // 等待页面加载完成
            await waitForElement('table[class*=ToolsRecommendList-Table]');
        }
    }

    await wait(500);

    const all_data = prepareChartData(Array.from(table_data.rows));
    const salt_all_range = all_data.reduce((sum, item) => sum + item.ans_local_salts, 0);

    // 输出控制台信息
    all_data.sort((a,b) => b.ans_local_salts- a.ans_local_salts).forEach(item => {
        const percentage = ((item.ans_local_salts / salt_all_range) * 100).toFixed(2);
        console.log(`${String(item.ans_local_salts).padStart(8,' ')} ${String(percentage).padStart(6,' ')}% ${item.ans_names} `);
    });

    // 创建可视化界面
    await createVisualization(all_data);
}

// 创建可视化界面(饼图 + 表格)
async function createVisualization(all_data) {
    // 准备饼图数据
    const pieData = preparePieChartData(all_data);

    // 创建主容器
    const container = document.createElement('div');
    container.style.cssText = `
        position: fixed;
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
        background: white;
        padding: 20px;
        border-radius: 8px;
        box-shadow: 0 4px 20px rgba(0,0,0,0.15);
        z-index: 10000;
        max-height: 90vh;
        overflow-y: auto;
        min-width: 800px;
    `;

    // 创建关闭按钮
    const closeButton = document.createElement('button');
    closeButton.textContent = '×';
    closeButton.style.cssText = `
        position: absolute;
        top: 10px;
        right: 10px;
        background: none;
        border: none;
        font-size: 24px;
        cursor: pointer;
        color: #666;
        width: 30px;
        height: 30px;
        border-radius: 50%;
        display: flex;
        align-items: center;
        justify-content: center;
    `;
    closeButton.onclick = () => container.remove();

    // 创建标题
    
    const title = document.createElement('h2');
    title.textContent = '知乎盐粒收益分析';
    title.style.cssText = `
        text-align: center;
        margin-bottom: 20px;
        color: #1890ff;
        font-size: 18px;
    `;

    // 创建饼图
    const pieChart = createPieChart(pieData, all_data.length);

    // 创建表格
    const tableHtml = createDataTable(all_data);
    const tableContainer = document.createElement('div');
    tableContainer.innerHTML = tableHtml;

    // 组装所有元素
    container.appendChild(closeButton);
    container.appendChild(title);
    container.appendChild(pieChart);
    container.appendChild(tableContainer);

    // 添加到页面
    document.body.appendChild(container);

    // 添加ESC键关闭功能
    const handleKeydown = (e) => {
        if (e.key === 'Escape') {
            container.remove();
            document.removeEventListener('keydown', handleKeydown);
        }
    };
    document.addEventListener('keydown', handleKeydown);

    // 点击背景关闭
    const overlay = document.createElement('div');
    overlay.style.cssText = `
        position: fixed;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: rgba(0,0,0,0.5);
        z-index: 9999;
    `;
    overlay.onclick = () => {
        container.remove();
        overlay.remove();
        document.removeEventListener('keydown', handleKeydown);
    };
    document.body.appendChild(overlay);
}

(async function() {
    'use strict';

    console.log(`🚀 [email protected] say hello!`);

    // 等待页面完全加载
    if (document.readyState === 'loading') {
        await new Promise(resolve => {
            document.addEventListener('DOMContentLoaded', resolve);
        });
    }

    // 添加样式
    // 获取昨天日期
    const yesterday_str = formatDate();

    // 点击进入『内容收益明细』
    const clickableDivs = document.querySelectorAll('div[class*="clickable"]');
    for (const div of clickableDivs) {
        const textContent = div.textContent.trim();
        if (textContent.includes('内容收益明细')) {
            div.click();
            break; // 只点击第一个匹配的元素
        }
    }

    // 等待页面跳转完成
    await wait(500);

    // 定义回调函数,在日期选择完成后执行
    const dataDrawCallback = async () => {
        console.log('回调函数执行:开始获取数据并绘制图表...');

        // 重新获取下一页按钮(因为页面可能已刷新)
        const next_button = document.querySelector('button[class*="CreatorPagination-nextButton"]');

        if (next_button) {
            await wait(720);
            await get_data_draw(next_button);
        } else {
            console.error('未找到下一页按钮,尝试直接获取数据...');
            await get_data_draw(null);
        }
    };

    // 初始设置日期
    await set_date(yesterday_str, dataDrawCallback);

    // 启动日期选择器变化监控(支持第二次及后续的时间选择)
    console.log('启动日期选择器变化监控...');
    const stopMonitoring = monitorDatePickerChanges(dataDrawCallback);

    // 页面卸载时停止监控
    window.addEventListener('beforeunload', () => {
        stopMonitoring();
    });

    // 添加停止监控的全局函数(用于调试)
    window.stopDatePickerMonitoring = stopMonitoring;
})();