在产品历史KOC助手中新增自定义排除UID列表功能,用户可以通过配置需要排除的达人UID,提升数据筛选的灵活性和准确性。同时,增加了相关的日志记录,便于用户了解排除列表的状态。这一改进优化了数据采集过程,确保用户能够更有效地管理和分析数据。
1598 lines
61 KiB
JavaScript
1598 lines
61 KiB
JavaScript
// ==UserScript==
|
||
// @name 蝉妈妈产品历史KOC助手
|
||
// @namespace https://bbs.tampermonkey.net.cn/
|
||
// @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
|
||
excludeUIDs:
|
||
title: 自定义排除列表
|
||
description: 需要排除的达人UID列表,每行一个UID(如:redian0805)
|
||
type: textarea
|
||
default: ""
|
||
==/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),
|
||
excludeUIDs: GM_getValue('filters.excludeUIDs', '')
|
||
},
|
||
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`
|
||
}
|
||
}
|
||
};
|
||
|
||
// 工具函数
|
||
/**
|
||
* 解析自定义排除UID列表
|
||
* @param {string} excludeText - 多行文本,每行一个UID
|
||
* @returns {Set} 包含所有需要排除的UID的Set
|
||
*/
|
||
function parseExcludeUIDs(excludeText) {
|
||
if (!excludeText || typeof excludeText !== 'string') {
|
||
return new Set();
|
||
}
|
||
|
||
// 按行分割,去除空行和空白字符
|
||
const uids = excludeText
|
||
.split('\n')
|
||
.map(line => line.trim())
|
||
.filter(line => line.length > 0);
|
||
|
||
return new Set(uids);
|
||
}
|
||
|
||
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;
|
||
|
||
// 解析自定义排除UID列表
|
||
const excludeUIDs = parseExcludeUIDs(CONFIG.filters.excludeUIDs);
|
||
if (excludeUIDs.size > 0) {
|
||
log(`自定义排除列表包含 ${excludeUIDs.size} 个达人UID`, 'info');
|
||
log(`排除列表: ${Array.from(excludeUIDs).join(', ')}`, 'debug');
|
||
} else {
|
||
log('未配置自定义排除列表', 'debug');
|
||
}
|
||
|
||
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 (excludeUIDs.has(author.达人UID)) {
|
||
log(`筛选掉达人 ${author.昵称} (${author.达人UID}): 在自定义排除列表中`, 'debug');
|
||
return false;
|
||
}
|
||
|
||
// 检查金额门槛
|
||
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');
|
||
|
||
// 显示自定义排除列表信息
|
||
const excludeUIDs = parseExcludeUIDs(CONFIG.filters.excludeUIDs);
|
||
if (excludeUIDs.size > 0) {
|
||
log(` - 自定义排除: ${excludeUIDs.size} 个达人UID`, 'info');
|
||
} else {
|
||
log(` - 自定义排除: 未配置`, '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();
|
||
}
|
||
})();
|