scriptCat/chanmama/productHistoryKOCHelper.user.js
intelligrow ce078e9da6 feat: 添加飞书同步功能,增强数据采集工具
在产品历史KOC助手中新增飞书同步功能,允许用户将采集到的数据同步到飞书多维表格。此更新包括飞书应用ID、密钥及表格ID的配置选项,支持对已存在记录的检查与批量上传,提升了数据管理的灵活性和效率。同时,优化了数据筛选条件,确保采集的数据符合用户需求。
2025-06-24 17:22:30 +08:00

1550 lines
59 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name 蝉妈妈产品历史KOC助手
// @namespace https://github.com/scriptCat/
// @version 1.0.0
// @description 蝉妈妈平台产品历史KOC数据分析和导出助手
// @author wangxi
// @match https://www.chanmama.com/promotionRank/*
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_log
// @grant GM_addStyle
// @grant GM_setClipboard
// @connect chanmama.com
// @connect api-service.chanmama.com
// @connect open.feishu.cn
// @require https://unpkg.com/sweetalert2@11
// @license MIT
// ==/UserScript==
/* ==UserConfig==
basic:
enabled:
title: 启用功能
description: 是否启用蝉妈妈产品历史KOC助手功能
type: checkbox
default: true
timeout:
title: 超时时间
description: 网络请求超时时间
type: number
default: 30
min: 10
max: 120
unit: 秒
debug:
title: 调试模式
description: 启用详细日志输出
type: checkbox
default: false
---
feishu:
enableSync:
title: 启用飞书同步
description: 是否将采集到的数据同步到飞书多维表格
type: checkbox
default: false
appId:
title: 飞书应用ID
description: 飞书开放平台应用的App ID
type: text
default: "cli_a73b734d0564d01c"
appSecret:
title: 飞书应用密钥
description: 飞书开放平台应用的App Secret
type: text
default: "xeKSIOpQiVW6oHeJPH4EvlZMZ1QmnCxx"
password: true
bitableAppId:
title: 多维表格App ID
description: 飞书多维表格的App ID
type: text
default: "ILIYb7MUCatbfNsdUMccHsgjnLf"
tableId:
title: 数据表ID
description: 存储KOC数据的表格ID
type: text
default: "tblK71li9kOfOc7C"
filters:
amountThreshold:
title: 金额门槛
description: 预估关联商品视频销售金额的最低门槛(元),低于此值停止采集
type: number
default: 10000
videoRatioThreshold:
title: 视频带货占比门槛
description: 视频销售金额占总销售金额的比例门槛如0.5表示视频销售需占总销售的50%以上
type: number
default: 0.5
maxFollowers:
title: 粉丝数上限
description: 达人粉丝数上限,只采集粉丝数少于此数值的达人
type: number
default: 500000
==/UserConfig== */
(function() {
'use strict';
// 检测依赖库加载状态
function checkLibraryStatus() {
if (typeof Swal !== 'undefined') {
log('SweetAlert2 库加载成功');
} else {
log('SweetAlert2 库未加载,将使用备用方案', 'warn');
}
}
// 配置常量
const CONFIG = {
debug: GM_getValue('basic.debug', false),
enabled: GM_getValue('basic.enabled', true),
timeout: GM_getValue('basic.timeout', 30) * 1000,
api: {
baseUrl: 'https://api-service.chanmama.com',
endpoint: '/v1/product/author/analysis'
},
pagination: {
pageSize: 30,
amountThreshold: GM_getValue('filters.amountThreshold', 10000)
},
filters: {
videoRatioThreshold: GM_getValue('filters.videoRatioThreshold', 0.5),
maxFollowers: GM_getValue('filters.maxFollowers', 500000)
},
feishu: {
enabled: GM_getValue('feishu.enableSync', false),
appId: GM_getValue('feishu.appId', ''),
appSecret: GM_getValue('feishu.appSecret', ''),
bitableAppId: GM_getValue('feishu.bitableAppId', ''),
tableId: GM_getValue('feishu.tableId', ''),
urls: {
tenantAccessToken: "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
bitableBatchCreate: (appId, tableId) =>
`https://open.feishu.cn/open-apis/bitable/v1/apps/${appId}/tables/${tableId}/records/batch_create`,
bitableSearchRecords: (appId, tableId) =>
`https://open.feishu.cn/open-apis/bitable/v1/apps/${appId}/tables/${tableId}/records/search`
}
}
};
// 工具函数
function log(message, level = 'info') {
const logMessage = `[${GM_info.script.name}] ${message}`;
// 强制输出所有日志到GM日志和控制台便于调试
GM_log(logMessage, level);
console.log(logMessage);
}
function showNotification(title, text, type = 'info') {
GM_notification({
title: title,
text: text,
timeout: 5000,
onclick: () => log('通知被点击')
});
}
function makeRequest(url, options = {}) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || 'GET',
url: url,
headers: {
'Content-Type': 'application/json',
...options.headers
},
data: options.data,
timeout: CONFIG.timeout,
anonymous: false,
onload: (response) => {
// 调试信息:记录详细的请求和响应
if (CONFIG.debug) {
log(`请求URL: ${url}`);
log(`响应状态: ${response.status} ${response.statusText}`);
log(`响应内容: ${response.responseText.substring(0, 500)}...`);
}
if (response.status >= 200 && response.status < 300) {
try {
const data = JSON.parse(response.responseText);
resolve(data);
} catch (error) {
resolve(response.responseText);
}
} else {
// 解析错误响应
let errorDetail = response.statusText;
try {
const errorData = JSON.parse(response.responseText);
errorDetail = errorData.msg || errorData.message || errorData.error || response.statusText;
} catch (e) {
// 如果无法解析JSON使用原始响应
errorDetail = response.responseText || response.statusText;
}
reject(new Error(`HTTP ${response.status}: ${errorDetail}`));
}
},
onerror: () => reject(new Error('网络请求失败')),
ontimeout: () => reject(new Error('请求超时'))
});
});
}
// 重试函数
const retry = async (fn, retries = 3, delay = 1000) => {
for (let i = 0; i < retries; i++) {
try {
return await fn();
} catch (error) {
if (i < retries - 1) {
log(`重试中... (${i + 1}/${retries})`, 'warn');
await new Promise((resolve) => setTimeout(resolve, delay));
} else {
throw error;
}
}
}
};
// 飞书API相关函数
async function fetchFeishuTenantAccessToken() {
if (!CONFIG.feishu.appId || !CONFIG.feishu.appSecret) {
throw new Error('飞书应用ID或密钥未配置');
}
log(`正在获取飞书访问令牌App ID: ${CONFIG.feishu.appId.substring(0, 8)}...`);
try {
const response = await retry(() => makeRequest(
CONFIG.feishu.urls.tenantAccessToken,
{
method: 'POST',
data: JSON.stringify({
app_id: CONFIG.feishu.appId,
app_secret: CONFIG.feishu.appSecret,
})
}
));
log(`飞书API响应: code=${response.code}, msg=${response.msg || '无'}`);
if (response.code !== 0) {
throw new Error(`获取飞书访问令牌失败: ${response.msg || response.message || '未知错误'}`);
}
if (!response.tenant_access_token) {
throw new Error('飞书返回的访问令牌为空');
}
log('飞书访问令牌获取成功');
return response.tenant_access_token;
} catch (error) {
log(`获取飞书访问令牌失败: ${error.message}`, 'error');
log(`请检查1.应用ID和密钥是否正确 2.应用是否有多维表格权限 3.网络连接是否正常`, 'error');
throw error;
}
}
/**
* 获取飞书表格中所有已存在的达人UID支持自动翻页
*/
async function getExistingKOCUIDs(accessToken) {
try {
const url = CONFIG.feishu.urls.bitableSearchRecords(CONFIG.feishu.bitableAppId, CONFIG.feishu.tableId);
const headers = {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
const existingUIDs = new Set();
let hasMore = true;
let pageToken = null;
let pageCount = 0;
log('开始获取飞书表格中已存在的达人UID...');
while (hasMore) {
pageCount++;
log(`获取第 ${pageCount} 页已存在的UID...`);
// 构造请求体
const requestBody = {
field_names: ['达人UID'], // 只获取达人UID字段
automatic_fields: false
};
// 构造URL参数
let requestUrl = url + '?page_size=500&user_id_type=open_id';
if (pageToken) {
requestUrl += `&page_token=${encodeURIComponent(pageToken)}`;
}
const response = await retry(() => makeRequest(requestUrl, {
method: 'POST',
data: JSON.stringify(requestBody),
headers: headers
}));
if (response.code !== 0) {
throw new Error(`获取已存在UID失败: ${response.msg || response.message || '未知错误'}`);
}
const data = response.data || {};
const items = data.items || [];
// 提取UID - 处理富文本格式
items.forEach(item => {
const fields = item.fields || {};
const uidField = fields['达人UID'];
let uid = '';
if (uidField) {
if (Array.isArray(uidField) && uidField.length > 0) {
// 处理富文本格式 [{"text": "值", "type": "text"}]
for (const item of uidField) {
if (item.type === 'text' && item.text) {
uid += item.text;
}
}
} else if (typeof uidField === 'string') {
// 处理普通文本格式
uid = uidField;
}
if (uid) {
existingUIDs.add(uid);
}
}
});
log(`${pageCount} 页获取到 ${items.length} 条记录`);
// 检查是否还有更多数据
hasMore = data.has_more || false;
pageToken = data.page_token || null;
// 防止无限循环
if (pageCount > 1000) {
log('达到最大翻页限制,停止获取', 'warn');
break;
}
// 批次间延迟,避免频率限制
if (hasMore) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
log(`总共获取 ${pageCount} 页,找到 ${existingUIDs.size} 个已存在的达人UID`);
return existingUIDs;
} catch (error) {
log(`获取已存在UID失败: ${error.message}`, 'error');
// 如果获取失败,返回空集合,继续处理
return new Set();
}
}
async function batchUploadKOCRecordsToFeishu(accessToken, kocDataList) {
try {
const url = CONFIG.feishu.urls.bitableBatchCreate(CONFIG.feishu.bitableAppId, CONFIG.feishu.tableId);
const headers = {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json',
};
log(`批量上传URL: ${url}`);
log(`上传记录数: ${kocDataList.length}`);
// 构造批量记录数据
const records = kocDataList.map(kocData => ({
fields: {
'昵称': kocData.昵称,
'达人UID': kocData.达人UID,
'粉丝数': kocData.粉丝数,
'类型': kocData.类型,
'口碑分': kocData.口碑分,
'关联商品销售量区间': kocData.关联商品销售量区间,
'关联商品销售金额区间': kocData.关联商品销售金额区间,
'预估关联商品销售量': kocData.预估关联商品销售量,
'预估关联商品销售金额': kocData.预估关联商品销售金额,
'预估关联商品视频销售量': kocData.预估关联商品视频销售量,
'预估关联商品视频销售金额': kocData.预估关联商品视频销售金额,
'视频带货占比': kocData.视频带货占比,
'关联商品蝉妈妈ID': kocData.关联商品蝉妈妈ID,
'采集时间': new Date().getTime(), // 毫秒级时间戳
}
}));
// 分批上传每批最多100条记录
const batchSize = 100;
const batches = [];
for (let i = 0; i < records.length; i += batchSize) {
batches.push(records.slice(i, i + batchSize));
}
log(`分为 ${batches.length} 批上传,每批最多 ${batchSize} 条记录`);
let totalUploaded = 0;
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
const body = JSON.stringify({ records: batch });
log(`上传第 ${i + 1}/${batches.length} 批,包含 ${batch.length} 条记录`);
const response = await retry(() => makeRequest(url, {
method: 'POST',
data: body,
headers: headers
}));
if (response.code !== 0) {
throw new Error(`批量上传到飞书失败: ${response.msg || response.message || '未知错误'}`);
}
totalUploaded += batch.length;
log(`${i + 1} 批上传成功: ${batch.length} 条记录`);
// 批次间延迟500ms避免频率限制
if (i < batches.length - 1) {
await new Promise(resolve => setTimeout(resolve, 500));
}
}
log(`批量上传完成: 总共 ${totalUploaded} 条记录`);
return { code: 0, uploaded: totalUploaded };
} catch (error) {
// 特殊处理403权限错误
if (error.message.includes('HTTP 403')) {
log('HTTP 403权限错误诊断:', 'error');
log(`- App ID: ${CONFIG.feishu.appId}`, 'error');
log(`- Bitable App ID: ${CONFIG.feishu.bitableAppId}`, 'error');
log(`- Table ID: ${CONFIG.feishu.tableId}`, 'error');
log('请检查以下几点:', 'error');
log('1. 飞书应用是否有多维表格权限', 'error');
log('2. App ID和App Secret是否正确', 'error');
log('3. 多维表格App ID和表格ID是否正确', 'error');
log('4. 应用是否已发布并获得权限', 'error');
throw new Error(`飞书权限错误 (403): 请检查应用权限配置`);
}
log(`批量上传到飞书失败: ${error.message}`, 'error');
throw error;
}
}
// 弹窗函数 - 带 SweetAlert2 备用方案
function showInfo(title, text) {
if (typeof Swal !== 'undefined') {
Swal.fire({
title: title,
text: text,
icon: 'info',
confirmButtonText: '确定'
});
} else {
alert(`${title}\n${text}`);
log(`Info: ${title} - ${text}`);
}
}
function showSuccess(title, text) {
if (typeof Swal !== 'undefined') {
Swal.fire({
title: title,
text: text,
icon: 'success',
timer: 3000,
showConfirmButton: false
});
} else {
showToast(`${title}: ${text}`, 'success');
log(`Success: ${title} - ${text}`);
}
}
function showError(title, text) {
if (typeof Swal !== 'undefined') {
Swal.fire({
title: title,
text: text,
icon: 'error',
confirmButtonText: '确定'
});
} else {
alert(`错误: ${title}\n${text}`);
log(`Error: ${title} - ${text}`, 'error');
}
}
async function showConfirm(title, text) {
if (typeof Swal !== 'undefined') {
const result = await Swal.fire({
title: title,
text: text,
icon: 'question',
showCancelButton: true,
confirmButtonText: '确定',
cancelButtonText: '取消'
});
return result.isConfirmed;
} else {
return confirm(`${title}\n${text}`);
}
}
/**
* 解析区间文本为平均值
* 例如: "750~1000" -> 875, "1w~2.5w" -> 17500
*/
function parseRangeText(rangeText) {
if (!rangeText || rangeText === '0' || rangeText === '') return 0;
// 正确处理万单位:先解析每个数值,再转换单位
const split = rangeText.split('~');
const values = split.map(s => {
const trimmed = s.trim();
if (trimmed.includes('w')) {
// 提取数字部分乘以10000
const numPart = parseFloat(trimmed.replace('w', '')) || 0;
return numPart * 10000;
} else {
return parseFloat(trimmed) || 0;
}
});
const result = values.reduce((sum, val) => sum + val, 0) / values.length;
return result;
}
/**
* 解析KOC数据
*/
function parseKOCData(apiResponse) {
if (!apiResponse || !apiResponse.data || !apiResponse.data.list) {
log('数据格式错误', 'error');
return [];
}
const results = [];
const kocList = apiResponse.data.list;
kocList.forEach((koc, index) => {
// 基础信息
const baseInfo = {
昵称: koc.nickname || '',
达人UID: koc.unique_id || koc.short_id || '',
粉丝数: koc.follower_count || 0,
类型: koc.label || '',
口碑分: (koc.reputation && koc.reputation.score) || 0,
关联商品销售量区间: koc.volume_text || '',
关联商品销售金额区间: koc.amount_text || '',
预估关联商品销售量: parseRangeText(koc.volume_text),
预估关联商品销售金额: parseRangeText(koc.amount_text)
};
// 计算关联商品视频的总销售量和金额
let totalVideoVolume = 0;
let totalVideoAmount = 0;
const videoProductIds = new Set();
if (koc.a && Array.isArray(koc.a)) {
koc.a.forEach((video, videoIndex) => {
if (video.product_id) {
videoProductIds.add(video.product_id);
}
const volumeValue = parseRangeText(video.volume_text);
const amountValue = parseRangeText(video.amount_text);
totalVideoVolume += volumeValue;
totalVideoAmount += amountValue;
});
}
baseInfo.预估关联商品视频销售量 = totalVideoVolume;
baseInfo.预估关联商品视频销售金额 = totalVideoAmount;
baseInfo.关联商品蝉妈妈ID = Array.from(videoProductIds).join(',');
// 计算视频带货占比限制最大值为1
const totalAmount = baseInfo.预估关联商品销售金额 || 0;
let videoRatio = totalAmount > 0 ? totalVideoAmount / totalAmount : 0;
videoRatio = Math.min(videoRatio, 1); // 限制最大值为1
baseInfo.视频带货占比 = videoRatio;
results.push(baseInfo);
});
return results;
}
// UI相关函数 - 创建简洁美观的悬浮窗
function createFloatingWindow() {
// 检查悬浮窗是否已存在
if (document.querySelector('.koc-floating-window')) {
log('悬浮窗已存在');
return true;
}
// 创建简洁的悬浮窗
const floatingWindow = document.createElement('div');
floatingWindow.className = 'koc-floating-window';
floatingWindow.innerHTML = `
<div class="koc-window-header">
<span class="koc-window-title">KOC数据采集</span>
<button class="koc-close-btn" title="关闭">×</button>
</div>
<div class="koc-window-content">
<div class="koc-api-status">
<div class="koc-status-indicator"></div>
<span class="koc-status-text">准备采集...</span>
</div>
${CONFIG.feishu.enabled ? `
<div class="koc-feishu-status">
<svg class="koc-feishu-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.94-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
<span class="koc-feishu-text">飞书同步已启用</span>
</div>
` : ''}
<div class="koc-actions">
<button class="koc-download-btn" type="button">
<svg class="koc-btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
<span class="koc-btn-text">开始采集</span>
</button>
</div>
</div>
`;
// 设置悬浮窗样式
floatingWindow.style.cssText = `
position: fixed;
bottom: 30px;
right: 30px;
width: 180px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
z-index: 9999;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
user-select: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
opacity: 0;
transform: translateY(20px) scale(0.95);
`;
// 添加美观的样式
GM_addStyle(`
.koc-floating-window {
animation: kocSlideIn 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
@keyframes kocSlideIn {
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.koc-window-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px 8px;
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
cursor: move;
user-select: none;
}
.koc-window-title {
font-size: 13px;
font-weight: 600;
color: #374151;
letter-spacing: 0.025em;
}
.koc-close-btn {
width: 20px;
height: 20px;
border: none;
background: transparent;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
color: #6b7280;
transition: all 0.2s ease;
font-weight: 300;
}
.koc-close-btn:hover {
background: rgba(239, 68, 68, 0.1);
color: #ef4444;
transform: scale(1.1);
}
.koc-window-content {
padding: 16px;
}
.koc-api-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: rgba(16, 185, 129, 0.05);
border-radius: 6px;
border: 1px solid rgba(16, 185, 129, 0.1);
margin-bottom: 12px;
}
.koc-actions {
display: flex;
flex-direction: column;
gap: 8px;
}
.koc-download-btn {
width: 100%;
padding: 12px 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
position: relative;
overflow: hidden;
}
.koc-download-btn::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
transition: left 0.5s;
}
.koc-download-btn:hover {
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(102, 126, 234, 0.4);
}
.koc-download-btn:hover::before {
left: 100%;
}
.koc-download-btn:active {
transform: translateY(0);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
}
.koc-download-btn:disabled {
background: linear-gradient(135deg, #d1d5db 0%, #9ca3af 100%);
cursor: not-allowed;
transform: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.koc-btn-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.koc-status-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
background: #10b981;
animation: kocPulse 2s infinite;
}
@keyframes kocPulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.5;
transform: scale(1.2);
}
}
.koc-status-text {
font-size: 11px;
color: #6b7280;
font-weight: 500;
}
.koc-feishu-status {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(59, 130, 246, 0.05);
border-radius: 6px;
border: 1px solid rgba(59, 130, 246, 0.1);
margin-bottom: 12px;
}
.koc-feishu-icon {
width: 16px;
height: 16px;
color: #3b82f6;
flex-shrink: 0;
}
.koc-feishu-text {
font-size: 11px;
color: #3b82f6;
font-weight: 500;
}
.koc-floating-window.dragging {
transition: none !important;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.15);
transform: scale(1.02);
}
/* 状态颜色 */
.koc-status.waiting .koc-status-indicator {
background: #10b981;
}
.koc-status.processing .koc-status-indicator {
background: #f59e0b;
animation: kocSpin 1s linear infinite;
}
.koc-status.success .koc-status-indicator {
background: #10b981;
}
.koc-status.error .koc-status-indicator {
background: #ef4444;
}
@keyframes kocSpin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.koc-floating-window {
bottom: 20px;
right: 20px;
width: 160px;
}
}
`);
// 添加到页面
document.body.appendChild(floatingWindow);
// 设置拖拽功能
setupSimpleDrag(floatingWindow);
// 设置事件监听器
setupFloatingWindowEvents(floatingWindow);
// 显示动画
setTimeout(() => {
floatingWindow.style.opacity = '1';
floatingWindow.style.transform = 'translateY(0) scale(1)';
}, 100);
return true;
}
// 更新悬浮窗状态
function updateFloatingWindowStatus(message, type = 'info') {
const floatingWindow = document.querySelector('.koc-floating-window');
if (!floatingWindow) return;
const statusText = floatingWindow.querySelector('.koc-status-text');
const statusIndicator = floatingWindow.querySelector('.koc-status-indicator');
if (statusText) {
statusText.textContent = message;
}
if (statusIndicator) {
statusIndicator.className = 'koc-status-indicator';
statusIndicator.classList.add(`koc-status-${type}`);
}
}
// 处理下载按钮点击
async function handleDownloadClick() {
const floatingWindow = document.querySelector('.koc-floating-window');
if (!floatingWindow) {
log('悬浮窗不存在', 'error');
return;
}
const downloadBtn = floatingWindow.querySelector('.koc-download-btn');
if (!downloadBtn) {
log('下载按钮不存在', 'error');
return;
}
if (downloadBtn.disabled) {
log('按钮已禁用,请等待当前操作完成', 'warn');
return;
}
try {
log('=== 开始KOC数据采集 ===', 'info');
// 设置按钮状态为处理中
setStatus(floatingWindow, 'processing', '正在采集数据...');
downloadBtn.disabled = true;
downloadBtn.innerHTML = `
<svg class="koc-btn-icon" viewBox="0 0 24 24" fill="currentColor" style="animation: spin 1s linear infinite;">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
</svg>
<span class="koc-btn-text">采集中...</span>
`;
// 从当前页面URL提取product_id
function extractProductIdFromUrl() {
const url = window.location.href;
const match = url.match(/promotionRank\/([^\.]+)\.html/);
return match ? match[1] : "";
}
// 计算时间范围近365天T-1结束
function getDateRange() {
const endDate = new Date();
endDate.setDate(endDate.getDate() - 1); // T-1
const startDate = new Date();
startDate.setDate(startDate.getDate() - 365); // 近365天
return {
start_date: startDate.toISOString().split('T')[0],
end_date: endDate.toISOString().split('T')[0]
};
}
const dateRange = getDateRange();
const productId = extractProductIdFromUrl();
const apiUrl = `${CONFIG.api.baseUrl}${CONFIG.api.endpoint}`;
log(`产品ID: ${productId}`, 'debug');
log(`时间范围: ${dateRange.start_date}${dateRange.end_date}`, 'debug');
let allData = [];
let currentPage = 1;
let shouldContinue = true;
while (shouldContinue) {
log(`请求第 ${currentPage} 页数据`, 'info');
// 构造请求参数
const requestParams = new URLSearchParams({
product_id: productId,
sort: 'amount',
order_by: 'desc',
has_contact: '0',
is_new_corp: '0',
self_play: '0',
shop_play: '0',
author_play: '1',
live_room_status: '0',
category: '',
reputation_level: '-1',
follower_count: '',
keyword: '',
page: currentPage.toString(),
size: CONFIG.pagination.pageSize.toString(),
take_product_method: '1',
start_date: dateRange.start_date,
end_date: dateRange.end_date
}).toString();
log(`请求参数: ${requestParams}`, 'debug');
// 发起API请求
const response = await makeRequest(`${apiUrl}?${requestParams}`, {
method: 'GET'
});
// 解析数据
const parsedData = parseKOCData(response);
if (parsedData.length === 0) {
log(`${currentPage} 页无数据,停止采集`, 'info');
break;
}
// 应用筛选条件
const filteredData = parsedData.filter(author => {
// 检查金额门槛
if (author.预估关联商品视频销售金额 < CONFIG.pagination.amountThreshold) {
log(`筛选掉达人 ${author.昵称}: 视频销售金额 ${author.预估关联商品视频销售金额} < ${CONFIG.pagination.amountThreshold}`, 'debug');
return false;
}
// 检查粉丝数上限
const followers = author.粉丝数 || 0;
if (followers >= CONFIG.filters.maxFollowers) {
log(`筛选掉达人 ${author.昵称}: 粉丝数 ${followers} >= ${CONFIG.filters.maxFollowers}`, 'debug');
return false;
}
// 检查视频带货占比(使用已计算的值)
const videoRatio = author.视频带货占比 || 0;
if (videoRatio < CONFIG.filters.videoRatioThreshold) {
log(`筛选掉达人 ${author.昵称}: 视频带货占比 ${videoRatio.toFixed(3)} < ${CONFIG.filters.videoRatioThreshold}`, 'debug');
return false;
}
return true;
});
log(`${currentPage} 页原始数据 ${parsedData.length} 条,筛选后 ${filteredData.length}`, 'info');
// 检查金额门槛 - 使用预估关联商品销售金额的最大值来决定是否停止采集
const amountValues = parsedData.map(author => author.预估关联商品销售金额 || 0);
const maxAmount = Math.max(...amountValues);
log(`${currentPage} 页最大金额: ${maxAmount}`, 'debug');
if (maxAmount < CONFIG.pagination.amountThreshold) {
log(`达到金额门槛 ${CONFIG.pagination.amountThreshold},停止采集`, 'info');
allData = allData.concat(filteredData);
shouldContinue = false;
} else {
allData = allData.concat(filteredData);
currentPage++;
// 防止无限循环最多采集100页
if (currentPage > 100) {
log('达到最大页数限制,停止采集', 'warn');
shouldContinue = false;
}
}
// 更新状态
setStatus(floatingWindow, 'processing', `已采集 ${allData.length} 条数据`);
// 延迟1秒避免请求过于频繁
await new Promise(resolve => setTimeout(resolve, 1000));
}
log(`=== 数据采集完成 ===`, 'info');
log(`总共采集 ${allData.length} 条符合条件的达人数据`, 'info');
// 显示采集结果的toast提示
showToast(`📊 采集完成:找到 ${allData.length} 个满足条件的达人`, 'success');
// 打印完整数据到日志
log('📊 KOC数据采集结果:', 'info');
log(`📈 共采集 ${allData.length} 条达人数据`, 'info');
log('📋 数据详情:', 'info');
log(JSON.stringify(allData, null, 2), 'info');
// 飞书同步处理
let feishuResult = 'Success';
let syncLogs = [];
let syncedCount = 0;
if (CONFIG.feishu.enabled && allData.length > 0) {
try {
log(`=== 开始同步到飞书多维表格 ===`, 'info');
setStatus(floatingWindow, 'processing', '同步到飞书中...');
// 检查飞书配置
if (!CONFIG.feishu.bitableAppId || !CONFIG.feishu.tableId) {
throw new Error('飞书多维表格配置不完整,请检查配置');
}
const accessToken = await fetchFeishuTenantAccessToken();
syncLogs.push('飞书访问令牌获取成功');
setStatus(floatingWindow, 'processing', '检查已存在记录...');
// 获取已存在的达人UID
const existingUIDs = await getExistingKOCUIDs(accessToken);
syncLogs.push(`查询到 ${existingUIDs.size} 个已存在的达人UID`);
// 过滤掉已存在的记录
const newRecords = allData.filter(kocData => !existingUIDs.has(kocData.达人UID));
const skippedCount = allData.length - newRecords.length;
log(`原始数据 ${allData.length} 条,已存在 ${skippedCount} 条,需要上传 ${newRecords.length} 条新记录`);
syncLogs.push(`总计 ${allData.length} 条记录,跳过 ${skippedCount} 条已存在,上传 ${newRecords.length} 条新记录`);
if (newRecords.length > 0) {
setStatus(floatingWindow, 'processing', `批量上传 ${newRecords.length} 条新记录...`);
const result = await batchUploadKOCRecordsToFeishu(accessToken, newRecords);
syncedCount = result.uploaded || newRecords.length;
syncLogs.push(`✅ 批量上传完成: ${syncedCount} 条记录`);
setStatus(floatingWindow, 'success', `上传完成: ${syncedCount} 条新记录`);
// 显示上传结果的toast提示
showToast(`📤 飞书同步:${skippedCount} 个重复跳过,${syncedCount} 个新增写入`, 'info');
} else {
syncLogs.push('✅ 没有需要上传的新记录');
setStatus(floatingWindow, 'success', '所有记录均已存在,无需上传');
// 显示全部重复的toast提示
showToast(`📋 飞书同步:${allData.length} 个达人均已存在,无新增记录`, 'warning');
}
log(`=== 飞书同步完成 ===`, 'info');
log(`成功同步 ${syncedCount} 条新数据到飞书多维表格`, 'info');
} catch (error) {
feishuResult = 'Failed';
syncLogs.push(`❌ 飞书同步失败: ${error.message}`);
log(`飞书同步失败: ${error.message}`, 'error');
setStatus(floatingWindow, 'error', '飞书同步失败');
showError('飞书同步失败', error.message);
// 显示同步失败的toast提示
showToast(`❌ 飞书同步失败:${error.message}`, 'error');
}
}
// 显示最终结果
const successMessage = CONFIG.feishu.enabled
? `已采集 ${allData.length} 条数据${syncedCount > 0 ? `,同步 ${syncedCount} 条新数据到飞书` : ',所有数据均已存在'}`
: `已采集 ${allData.length} 条符合条件的达人数据,请查看控制台`;
setStatus(floatingWindow, 'success', `采集完成 (${allData.length}条)`);
showSuccess('采集成功', successMessage);
// 显示最终结果的详细toast
if (CONFIG.feishu.enabled) {
if (syncedCount > 0) {
showToast(`✅ 任务完成:采集 ${allData.length} 个达人,新增 ${syncedCount} 个到飞书`, 'success');
} else {
showToast(`✅ 任务完成:采集 ${allData.length} 个达人,全部已存在`, 'info');
}
} else {
showToast(`✅ 任务完成:采集 ${allData.length} 个符合条件的达人`, 'success');
}
// 5秒后恢复按钮状态
setTimeout(() => {
resetButton(floatingWindow);
}, 5000);
} catch (error) {
log(`=== 数据采集失败 ===`, 'error');
log(`错误详情: ${error.message}`, 'error');
log(`错误堆栈: ${error.stack}`, 'error');
setStatus(floatingWindow, 'error', '采集失败');
showError('采集失败', error.message);
// 5秒后恢复按钮状态
setTimeout(() => {
resetButton(floatingWindow);
}, 5000);
}
}
// 主逻辑
async function init() {
if (!CONFIG.enabled) {
log('脚本已禁用');
return;
}
try {
// 检测依赖库状态
checkLibraryStatus();
// 检查飞书配置
if (CONFIG.feishu.enabled) {
if (!CONFIG.feishu.appId || !CONFIG.feishu.appSecret) {
log('⚠️ 飞书同步已启用但应用ID或密钥未配置', 'warn');
showToast('飞书配置不完整请检查应用ID和密钥', 'warning');
} else if (!CONFIG.feishu.bitableAppId || !CONFIG.feishu.tableId) {
log('⚠️ 飞书多维表格配置不完整', 'warn');
showToast('飞书多维表格配置不完整请检查多维表格App ID和数据表ID', 'warning');
} else {
log('✅ 飞书同步配置完整', 'info');
}
} else {
log(' 飞书同步未启用', 'info');
}
// 输出启动信息
log('🎯 KOC数据采集工具启动', 'info');
log(`📋 筛选条件:`, 'info');
log(` - 金额门槛: ${CONFIG.pagination.amountThreshold}`, 'info');
log(` - 粉丝数上限: <${CONFIG.filters.maxFollowers.toLocaleString()}`, 'info');
log(` - 视频带货占比: ≥${(CONFIG.filters.videoRatioThreshold * 100).toFixed(1)}%`, 'info');
// 等待页面加载完成
await waitForPageLoad();
// 检查是否在正确的页面
if (!isValidPage()) {
log('当前页面不支持此功能');
return;
}
// 创建悬浮窗
let windowCreated = createFloatingWindow();
if (!windowCreated) {
log('首次创建悬浮窗失败,延迟重试...', 'warn');
setTimeout(() => {
windowCreated = createFloatingWindow();
if (!windowCreated) {
log('延迟重试失败设置DOM观察器', 'warn');
setupWindowObserver();
}
}, 2000);
}
showToast('KOC数据采集工具已就绪', 'success');
} catch (error) {
log(`初始化失败: ${error.message}`, 'error');
showToast('初始化失败', 'error');
}
}
function setupWindowObserver() {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === 'childList') {
// 检查是否已经有悬浮窗
if (document.querySelector('.koc-floating-window')) {
observer.disconnect();
return;
}
// 简单创建悬浮窗
createFloatingWindow();
observer.disconnect();
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
log('DOM观察器已设置');
}
function waitForPageLoad() {
return new Promise((resolve) => {
if (document.readyState === 'complete') {
resolve();
} else {
window.addEventListener('load', resolve);
}
});
}
function isValidPage() {
// 检查是否在蝉妈妈的推广排名页面
return window.location.hostname.includes('chanmama.com') &&
window.location.pathname.includes('/promotionRank');
}
// 备用拖拽方案
function setupSimpleDrag(floatingWindow) {
const header = floatingWindow.querySelector('.koc-window-header');
let isDragging = false;
let startX, startY, startLeft, startTop;
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.koc-close-btn')) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
// 获取当前位置正确处理bottom/right定位
const rect = floatingWindow.getBoundingClientRect();
startLeft = rect.left;
startTop = rect.top;
floatingWindow.classList.add('dragging');
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
let newLeft = startLeft + deltaX;
let newTop = startTop + deltaY;
// 限制在视窗内
const maxX = window.innerWidth - floatingWindow.offsetWidth;
const maxY = window.innerHeight - floatingWindow.offsetHeight;
newLeft = Math.max(0, Math.min(newLeft, maxX));
newTop = Math.max(0, Math.min(newTop, maxY));
// 设置新位置清除bottom/right定位
floatingWindow.style.left = newLeft + 'px';
floatingWindow.style.top = newTop + 'px';
floatingWindow.style.right = 'auto';
floatingWindow.style.bottom = 'auto';
});
document.addEventListener('mouseup', () => {
if (isDragging) {
isDragging = false;
floatingWindow.classList.remove('dragging');
}
});
}
// 设置悬浮窗事件
function setupFloatingWindowEvents(floatingWindow) {
const downloadBtn = floatingWindow.querySelector('.koc-download-btn');
const closeBtn = floatingWindow.querySelector('.koc-close-btn');
// 关闭功能
closeBtn.addEventListener('click', () => {
floatingWindow.style.opacity = '0';
floatingWindow.style.transform = 'translateY(20px) scale(0.95)';
setTimeout(() => {
floatingWindow.remove();
}, 300);
});
// 下载按钮点击 - 直接调用handleDownloadClick
downloadBtn.addEventListener('click', handleDownloadClick);
}
// 设置状态
function setStatus(floatingWindow, status, text) {
const apiStatusContainer = floatingWindow.querySelector('.koc-api-status');
const apiStatusText = floatingWindow.querySelector('.koc-api-status .koc-status-text');
if (apiStatusContainer && apiStatusText) {
// 移除所有状态类
apiStatusContainer.classList.remove('waiting', 'processing', 'success', 'error');
// 添加新状态类
apiStatusContainer.classList.add(status);
apiStatusText.textContent = text;
// 根据状态更新样式
switch (status) {
case 'processing':
apiStatusContainer.style.background = 'rgba(245, 158, 11, 0.1)';
apiStatusContainer.style.borderColor = 'rgba(245, 158, 11, 0.2)';
break;
case 'success':
apiStatusContainer.style.background = 'rgba(16, 185, 129, 0.1)';
apiStatusContainer.style.borderColor = 'rgba(16, 185, 129, 0.2)';
break;
case 'error':
apiStatusContainer.style.background = 'rgba(239, 68, 68, 0.1)';
apiStatusContainer.style.borderColor = 'rgba(239, 68, 68, 0.2)';
break;
default:
apiStatusContainer.style.background = 'rgba(16, 185, 129, 0.05)';
apiStatusContainer.style.borderColor = 'rgba(16, 185, 129, 0.1)';
}
}
}
// 重置按钮
function resetButton(floatingWindow) {
const downloadBtn = floatingWindow.querySelector('.koc-download-btn');
if (downloadBtn) {
downloadBtn.disabled = false;
downloadBtn.innerHTML = `
<svg class="koc-btn-icon" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
</svg>
<span class="koc-btn-text">开始采集</span>
`;
}
// 恢复状态显示
const apiStatusText = floatingWindow.querySelector('.koc-api-status .koc-status-text');
if (apiStatusText) {
apiStatusText.textContent = '准备采集...';
}
setStatus(floatingWindow, 'waiting', '准备采集...');
}
// 创建简洁的toast提示
function showToast(message, type = 'info') {
// 移除已存在的toast
const existingToast = document.querySelector('.koc-toast');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = `koc-toast koc-toast-${type}`;
toast.innerHTML = `
<div class="koc-toast-icon">
${getToastIcon(type)}
</div>
<div class="koc-toast-message">${message}</div>
`;
// 添加toast样式
GM_addStyle(`
.koc-toast {
position: fixed;
top: 20px;
right: 20px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 12px;
padding: 16px 20px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
z-index: 10000;
display: flex;
align-items: center;
gap: 12px;
font-size: 15px;
font-weight: 600;
max-width: 350px;
min-width: 280px;
animation: kocToastSlideIn 0.3s ease;
transition: all 0.3s ease;
}
@keyframes kocToastSlideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.koc-toast-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.koc-toast-message {
color: #374151;
line-height: 1.4;
}
.koc-toast-success {
border-left: 5px solid #10b981;
}
.koc-toast-error {
border-left: 5px solid #ef4444;
}
.koc-toast-info {
border-left: 5px solid #3b82f6;
}
.koc-toast-warning {
border-left: 5px solid #f59e0b;
}
.koc-toast.fade-out {
opacity: 0;
transform: translateX(100%);
}
`);
document.body.appendChild(toast);
// 3秒后自动消失
setTimeout(() => {
toast.classList.add('fade-out');
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
}, 3000);
// 点击关闭
toast.addEventListener('click', () => {
toast.classList.add('fade-out');
setTimeout(() => {
if (toast.parentNode) {
toast.remove();
}
}, 300);
});
}
// 获取toast图标
function getToastIcon(type) {
switch (type) {
case 'success':
return `<svg viewBox="0 0 24 24" fill="currentColor" style="color: #10b981;">
<path d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`;
case 'error':
return `<svg viewBox="0 0 24 24" fill="currentColor" style="color: #ef4444;">
<path d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`;
case 'warning':
return `<svg viewBox="0 0 24 24" fill="currentColor" style="color: #f59e0b;">
<path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"/>
</svg>`;
default:
return `<svg viewBox="0 0 24 24" fill="currentColor" style="color: #3b82f6;">
<path d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>`;
}
}
// 页面加载完成后运行
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();