// ==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;
})();