// ==UserScript== // @name 闲鱼收藏卖家提醒 // @namespace https://www.goofish.com/ // @version 1.5.2 // @description v1.5.2:性能优化——存储内存缓存、角标不重建、切断 MutationObserver 自触发死循环、接口解析挪到空闲帧、pageItems 合并写入;功能与 v1.5.1 一致(详情页推荐位同样标红) // @author You // @match *://www.goofish.com/* // @match *://h5api.m.goofish.com/* // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_registerMenuCommand // @grant GM_xmlhttpRequest // @connect * // @run-at document-end // @license MIT // ==/UserScript== (function () { 'use strict'; // ============================================================ // 【第一步】立刻创建状态指示灯 —— 不依赖任何其他代码 // 只要脚本在运行,右下角就一定有个点 // ============================================================ var statusDot = null; var initError = null; function createStatusDot() { try { if (!document.body) { document.addEventListener('DOMContentLoaded', createStatusDot, { once: true }); return; } if (statusDot) return; // 注入最小样式 var st = document.createElement('style'); st.textContent = '#xy-status-dot{position:fixed!important;bottom:16px!important;right:16px!important;z-index:2147483647!important;width:14px!important;height:14px!important;border-radius:50%!important;box-shadow:0 0 10px currentColor!important;cursor:pointer!important;transition:background .3s!important;border:2px solid #fff!important;}'; document.head.appendChild(st); statusDot = document.createElement('div'); statusDot.id = 'xy-status-dot'; statusDot.style.background = '#ff9800'; statusDot.style.color = '#ff9800'; statusDot.title = '闲鱼收藏提醒脚本运行中(橙色=初始化中)\n点击查看调试信息'; statusDot.addEventListener('click', showDebugInfo); document.body.appendChild(statusDot); console.log('%c[闲鱼收藏提醒] 状态指示灯已创建 ✓', 'color:#4caf50;font-weight:bold;font-size:14px'); } catch (e) { console.error('[闲鱼收藏提醒] 创建状态指示灯失败:', e); initError = e; } } function setStatus(color, title) { try { if (!statusDot) return; statusDot.style.background = color; statusDot.style.color = color; if (title) statusDot.title = title; } catch (e) { } } // 立即执行(document-end 时 body 应该存在,但保险起见) createStatusDot(); // ============================================================ // 配置 // ============================================================ var CONFIG = { FAV_KEY: 'xianyu_favorite_sellers', VISIT_KEY: 'xianyu_visit_history', CACHE_TTL: 3600000, TOAST_DURATION: 10000, MAX_VISIT: null, }; // ============================================================ // v1.5.0:云端共享数据库配置(Supabase) // 使用前请在油猴菜单里填好 URL 和 Anon Key,或直接改这里 // ============================================================ var CLOUD_CONFIG = { ENABLED: true, // 云端同步总开关 SUPABASE_URL: '', // 例: https://xxxx.supabase.co SUPABASE_ANON_KEY: '', // Supabase 的 anon public key SYNC_INTERVAL: 86400000, // 同步间隔:24小时(毫秒,一天一次) DEVICE_ID_KEY: 'xianyu_device_id', SHARED_KEY: 'xianyu_shared_sellers', SHARED_LAST_SYNC_KEY: 'xianyu_shared_last_sync', CLOUD_ENABLED_KEY: 'xianyu_cloud_enabled', UPLOADED_FAV_KEY: 'xianyu_uploaded_fav', UPLOADED_VISIT_KEY: 'xianyu_uploaded_visit', }; // ============================================================ // 工具函数 // ============================================================ function log() { var args = Array.prototype.slice.call(arguments); console.log.apply(console, ['%c[闲鱼收藏提醒]', 'color:#ff6b00;font-weight:bold'].concat(args)); } function warn() { var args = Array.prototype.slice.call(arguments); console.warn.apply(console, ['%c[闲鱼收藏提醒]', 'color:#ff6b00;font-weight:bold'].concat(args)); } function getUserId(url) { try { var u = new URL(url, location.href); return u.searchParams.get('userId') || u.searchParams.get('user_id'); } catch (e) { return null; } } function isProfile(url) { return /\/personal(\?|$)/.test(url) && !!getUserId(url); } // ---- v1.4.0 新增:商品详情页识别 ---- function isItemPage(url) { try { var u = new URL(url, location.href); var p = u.pathname; return (p === '/item' || p.indexOf('/item/') === 0) && !!u.searchParams.get('id'); } catch (e) { return false; } } function getItemId(url) { try { var u = new URL(url, location.href); return u.searchParams.get('id'); } catch (e) { return null; } } // ---- v1.4.5 新增:列表页识别(搜索页 / 首页推荐流) ---- function isSearchPage(url) { try { var u = new URL(url, location.href); var p = u.pathname; return (p === '/search' || p.indexOf('/search/') === 0 || p === '/' || p === '/index'); } catch (e) { return false; } } function isCollectionPage(url) { try { return /\/collection/.test(url); } catch (e) { return false; } } function getItemIdFromHref(href) { try { var u = new URL(href, location.href); return u.searchParams.get('id'); } catch (e) { return null; } } // 识别列表/搜索类接口(返回商品列表,含 userId/sellerId + itemId) function isListApi(url) { if (!url) return false; var l = String(url).toLowerCase(); if (l.indexOf('favorite') >= 0 || l.indexOf('favlist') >= 0 || l.indexOf('fav_list') >= 0 || l.indexOf('my_fav') >= 0) return false; // v1.4.6 扩大:所有 h5api/mtop/api 接口,以及 search/feed/recommend/sug return l.indexOf('/h5/') >= 0 || l.indexOf('mtop') >= 0 || l.indexOf('api') >= 0 || l.indexOf('search') >= 0 || l.indexOf('feed') >= 0 || l.indexOf('recommend') >= 0 || l.indexOf('sug') >= 0; } function timeAgo(ts) { var d = Date.now() - ts, m = Math.floor(d / 60000); if (m < 1) return '刚刚'; if (m < 60) return m + '分钟前'; var h = Math.floor(m / 60); if (h < 24) return h + '小时前'; var day = Math.floor(h / 24); if (day < 30) return day + '天前'; return new Date(ts).toLocaleDateString('zh-CN'); } // ============================================================ // 存储 // ============================================================ // v1.5.2:存储读缓存(性能优化核心) // 原来 FavStore/VisitStore/ItemUserStore 每读一次都是 GM_getValue + JSON.parse, // 而一次高亮要遍历几十张卡、每卡读 3~5 次 → 单次高亮上百次解析,这是卡顿主因。 // 现在内存缓存 5 秒,写入时同步更新缓存,跨标签页最多延迟 5 秒。 var StoreCache = { _c: {}, ttl: 5000, get: function (name, loader) { var e = this._c[name]; if (e && (Date.now() - e.ts) < this.ttl) return e.v; var v = null; try { v = loader(); } catch (err) { v = null; } this._c[name] = { v: v, ts: Date.now() }; return v; }, put: function (name, v) { this._c[name] = { v: v, ts: Date.now() }; }, drop: function (name) { delete this._c[name]; } }; var FavStore = { get: function () { var v = StoreCache.get('fav', function () { try { var raw = GM_getValue(CONFIG.FAV_KEY, null); if (!raw) return { data: {}, updatedAt: 0 }; return typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (e) { warn('读取收藏失败', e); return { data: {}, updatedAt: 0 }; } }); return v || { data: {}, updatedAt: 0 }; }, set: function (d) { var obj = { data: d, updatedAt: Date.now() }; StoreCache.put('fav', obj); try { GM_setValue(CONFIG.FAV_KEY, JSON.stringify(obj)); log('收藏索引已更新:', Object.keys(d).length, '个卖家'); } catch (e) { warn('保存收藏失败', e); } }, check: function (id) { return this.get().data[id] || null; }, clear: function () { StoreCache.drop('fav'); try { GM_deleteValue(CONFIG.FAV_KEY); } catch (e) { } }, expired: function () { var s = this.get(); return !s.updatedAt || (Date.now() - s.updatedAt) > CONFIG.CACHE_TTL; } }; var VisitStore = { get: function () { var v = StoreCache.get('visit', function () { try { var raw = GM_getValue(CONFIG.VISIT_KEY, null); if (!raw) return {}; return typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (e) { warn('读取访问历史失败', e); return {}; } }); return v || {}; }, set: function (d) { StoreCache.put('visit', d); try { GM_setValue(CONFIG.VISIT_KEY, JSON.stringify(d)); } catch (e) { warn('保存访问历史失败', e); } }, check: function (id) { return this.get()[id] || null; }, record: function (id) { var all = this.get(), now = Date.now(), rec = all[id]; if (rec) { rec.visitCount = (rec.visitCount || 1) + 1; rec.lastVisit = now; rec.isRevisit = true; } else { rec = { firstVisit: now, lastVisit: now, visitCount: 1, isRevisit: false }; } all[id] = rec; // v1.4.4:无上限,访问历史不再淘汰,永久保留全部记录 this.set(all); return rec; }, clear: function () { StoreCache.drop('visit'); try { GM_deleteValue(CONFIG.VISIT_KEY); } catch (e) { } } }; // ---- v1.4.15:itemId -> userId 映射 ---- // 商品详情页/卖家主页访问时采集,搜索页反查 var ITEM_USER_KEY = 'xianyu_item_user_map'; var ItemUserStore = { get: function () { var v = StoreCache.get('itemuser', function () { try { var raw = GM_getValue(ITEM_USER_KEY, null); return raw ? JSON.parse(raw) : {}; } catch (e) { return {}; } }); return v || {}; }, set: function (d) { StoreCache.put('itemuser', d); try { GM_setValue(ITEM_USER_KEY, JSON.stringify(d)); } catch (e) { } }, record: function (itemId, userId) { if (!itemId || !userId) return; var all = this.get(); if (String(all[String(itemId)]) === String(userId)) return; // 未变化不写 all[String(itemId)] = String(userId); this.set(all); log('itemId→userId 映射已保存:', itemId, '→', userId); }, // v1.5.2:主页一次采到几百个 itemId,逐个 record 会做几百次读+全量写,改成批量一次写 recordBatch: function (itemIds, userId) { if (!itemIds || !itemIds.length || !userId) return 0; var all = this.get(), changed = 0; for (var i = 0; i < itemIds.length; i++) { var id = String(itemIds[i]); if (String(all[id]) !== String(userId)) { all[id] = String(userId); changed++; } } if (!changed) return 0; this.set(all); log('itemId→userId 批量保存:', changed, '条 →', userId); return changed; }, getUserId: function (itemId) { return this.get()[String(itemId)] || null; }, clear: function () { StoreCache.drop('itemuser'); try { GM_deleteValue(ITEM_USER_KEY); } catch (e) { } } }; // ---- v1.4.16:sellerId(加密/平台ID) → userId(明文主页ID) 别名 ---- var SID_ALIAS_KEY = 'xianyu_sid_alias'; var SidAliasStore = { get: function () { var v = StoreCache.get('sidalias', function () { try { var raw = GM_getValue(SID_ALIAS_KEY, null); return raw ? JSON.parse(raw) : {}; } catch (e) { return {}; } }); return v || {}; }, set: function (d) { StoreCache.put('sidalias', d); try { GM_setValue(SID_ALIAS_KEY, JSON.stringify(d)); } catch (e) { } }, record: function (sellerId, userId) { if (!sellerId || !userId) return; if (String(sellerId) === String(userId)) return; var all = this.get(); if (String(all[String(sellerId)]) === String(userId)) return; // 未变化不写 all[String(sellerId)] = String(userId); this.set(all); log('sellerId→userId 别名已保存:', sellerId, '→', userId); }, resolve: function (sellerId) { if (!sellerId) return null; return this.get()[String(sellerId)] || null; }, clear: function () { StoreCache.drop('sidalias'); try { GM_deleteValue(SID_ALIAS_KEY); } catch (e) { } } }; // ============================================================ // v1.5.0:SharedStore —— 云端共享卖家数据本地缓存 // 数据格式: { sellerId: { favCount: N, visitCount: N, updatedAt: ts } } // ============================================================ function getDeviceId() { try { var did = GM_getValue(CLOUD_CONFIG.DEVICE_ID_KEY, ''); if (did) return did; // 生成随机设备ID(匿名,不包含任何个人信息) did = 'dev_' + Date.now().toString(36) + '_' + Math.random().toString(36).substring(2, 12); GM_setValue(CLOUD_CONFIG.DEVICE_ID_KEY, did); return did; } catch (e) { return 'dev_unknown'; } } function isCloudEnabled() { try { var v = GM_getValue(CLOUD_CONFIG.CLOUD_ENABLED_KEY, ''); if (v === '') return CLOUD_CONFIG.ENABLED; return v === '1'; } catch (e) { return CLOUD_CONFIG.ENABLED; } } function setCloudEnabled(on) { try { GM_setValue(CLOUD_CONFIG.CLOUD_ENABLED_KEY, on ? '1' : '0'); } catch (e) { } } var SharedStore = { get: function () { var v = StoreCache.get('shared', function () { try { var raw = GM_getValue(CLOUD_CONFIG.SHARED_KEY, null); return raw ? JSON.parse(raw) : {}; } catch (e) { return {}; } }); return v || {}; }, set: function (d) { StoreCache.put('shared', d); try { GM_setValue(CLOUD_CONFIG.SHARED_KEY, JSON.stringify(d)); } catch (e) { } }, getSharedFavCount: function (sid) { if (!sid) return 0; var d = this.get(); var rec = d[String(sid)]; return rec ? (rec.favCount || 0) : 0; }, getSharedVisitCount: function (sid) { if (!sid) return 0; var d = this.get(); var rec = d[String(sid)]; return rec ? (rec.visitCount || 0) : 0; }, isSharedFav: function (sid) { return this.getSharedFavCount(sid) > 0; }, isSharedVisit: function (sid) { return this.getSharedVisitCount(sid) > 0; }, merge: function (records) { // records: [{seller_id, fav_count, visit_count, updated_at}] if (!records || !records.length) return 0; var all = this.get(); var merged = 0; records.forEach(function (r) { var sid = String(r.seller_id || r.sellerId || ''); if (!sid) return; all[sid] = { favCount: r.fav_count || r.favCount || 0, visitCount: r.visit_count || r.visitCount || 0, updatedAt: r.updated_at || r.updatedAt || Date.now() }; merged++; }); this.set(all); return merged; }, count: function () { return Object.keys(this.get()).length; }, clear: function () { StoreCache.drop('shared'); try { GM_setValue(CLOUD_CONFIG.SHARED_KEY, '{}'); } catch (e) { } } }; // ---- v1.5.0:云端同步 ---- function cloudRequest(method, path, body, onSuccess, onError) { if (!CLOUD_CONFIG.SUPABASE_URL || !CLOUD_CONFIG.SUPABASE_ANON_KEY) { if (onError) onError(new Error('Supabase 未配置')); return; } var url = CLOUD_CONFIG.SUPABASE_URL.replace(/\/$/, '') + path; GM_xmlhttpRequest({ method: method, url: url, headers: { 'apikey': CLOUD_CONFIG.SUPABASE_ANON_KEY, 'Authorization': 'Bearer ' + CLOUD_CONFIG.SUPABASE_ANON_KEY, 'Content-Type': 'application/json', 'Prefer': 'return=representation' }, data: body ? JSON.stringify(body) : null, onload: function (res) { try { var data = res.responseText ? JSON.parse(res.responseText) : null; if (res.status >= 200 && res.status < 300) { if (onSuccess) onSuccess(data, res.status); } else { warn('云端请求失败 [' + res.status + ']', res.responseText); if (onError) onError(new Error('HTTP ' + res.status)); } } catch (e) { warn('云端响应解析失败', e); if (onError) onError(e); } }, onerror: function (e) { warn('云端网络错误', e); if (onError) onError(e); }, timeout: 15000 }); } // 上传本地收藏/浏览卖家ID到云端(增量:只传上次没传过的) function syncUpload() { if (!isCloudEnabled()) return; if (!CLOUD_CONFIG.SUPABASE_URL) return; var deviceId = getDeviceId(); // 收集本地所有收藏卖家ID var favData = FavStore.get().data || {}; var allFavIds = Object.keys(favData); // 收集本地所有浏览卖家ID var visitData = VisitStore.get() || {}; var allVisitIds = Object.keys(visitData); // 读取上次已上传的记录 var uploadedFav = {}; var uploadedVisit = {}; try { var rawF = GM_getValue(CLOUD_CONFIG.UPLOADED_FAV_KEY, '{}'); uploadedFav = rawF ? JSON.parse(rawF) : {}; } catch (e) { } try { var rawV = GM_getValue(CLOUD_CONFIG.UPLOADED_VISIT_KEY, '{}'); uploadedVisit = rawV ? JSON.parse(rawV) : {}; } catch (e) { } // 计算新增的ID(没传过的) var newFavIds = []; allFavIds.forEach(function (id) { if (!uploadedFav[id]) newFavIds.push(id); }); var newVisitIds = []; allVisitIds.forEach(function (id) { if (!uploadedVisit[id]) newVisitIds.push(id); }); if (!newFavIds.length && !newVisitIds.length) { log('云端上传: 无新增卖家,跳过'); return; } // 调用 Supabase RPC 函数 report_activity cloudRequest('POST', '/rest/v1/rpc/report_activity', { p_fav_ids: newFavIds, p_visit_ids: newVisitIds, p_device_id: deviceId }, function (data) { // 上传成功后,记录这些ID已上传 newFavIds.forEach(function (id) { uploadedFav[id] = Date.now(); }); newVisitIds.forEach(function (id) { uploadedVisit[id] = Date.now(); }); try { GM_setValue(CLOUD_CONFIG.UPLOADED_FAV_KEY, JSON.stringify(uploadedFav)); } catch (e) { } try { GM_setValue(CLOUD_CONFIG.UPLOADED_VISIT_KEY, JSON.stringify(uploadedVisit)); } catch (e) { } log('云端上传成功(增量): 新增收藏' + newFavIds.length + '个, 新增浏览' + newVisitIds.length + '个 | 累计已上传收藏' + Object.keys(uploadedFav).length + '个, 浏览' + Object.keys(uploadedVisit).length + '个'); }, function (err) { warn('云端上传失败', err); }); } // 从云端下载共享卖家数据(增量) function syncDownload() { if (!isCloudEnabled()) return; if (!CLOUD_CONFIG.SUPABASE_URL) return; var lastSync = GM_getValue(CLOUD_CONFIG.SHARED_LAST_SYNC_KEY, '2000-01-01T00:00:00Z'); // 拉取最近更新的共享卖家(取最近500条,按更新时间倒序) cloudRequest('GET', '/rest/v1/shared_sellers?select=seller_id,fav_count,visit_count,updated_at&order=updated_at.desc&limit=500', null, function (data) { if (Array.isArray(data)) { var n = SharedStore.merge(data); GM_setValue(CLOUD_CONFIG.SHARED_LAST_SYNC_KEY, new Date().toISOString()); log('云端下载成功: 合并' + n + '条共享卖家, 本地缓存共' + SharedStore.count() + '个'); } }, function (err) { warn('云端下载失败', err); }); } function syncNow() { log('手动触发云端同步...'); doSync(); } var syncTimer = null; function shouldSyncNow() { var last = 0; try { last = parseInt(GM_getValue('xianyu_last_sync_time', '0')) || 0; } catch (e) { } if (!last) return true; return (Date.now() - last) >= CLOUD_CONFIG.SYNC_INTERVAL; } function markSynced() { try { GM_setValue('xianyu_last_sync_time', String(Date.now())); } catch (e) { } } function doSync() { syncUpload(); setTimeout(function () { syncDownload(); markSynced(); }, 1000); } function startSyncTimer() { if (syncTimer) clearInterval(syncTimer); if (!isCloudEnabled()) { log('云端同步已关闭'); return; } if (!CLOUD_CONFIG.SUPABASE_URL) { log('云端同步未配置 Supabase URL,跳过。请在油猴菜单中配置。'); return; } // 启动后5秒检查:距上次同步满24小时才同步 setTimeout(function () { if (shouldSyncNow()) { log('距上次同步已满24小时,开始同步...'); doSync(); } else { var last = 0; try { last = parseInt(GM_getValue('xianyu_last_sync_time', '0')) || 0; } catch (e) { } var hoursLeft = ((CLOUD_CONFIG.SYNC_INTERVAL - (Date.now() - last)) / 3600000).toFixed(1); log('距上次同步未满24小时(还剩' + hoursLeft + '小时),跳过自动同步'); } }, 5000); // 每小时检查一次,到点了就同步 syncTimer = setInterval(function () { if (shouldSyncNow()) { log('定时器触发:满24小时,开始同步...'); doSync(); } }, 3600000); log('云端同步已启动(每小时检查一次,满24小时才同步,刷新页面不会重复同步)'); } // ---- v1.4.0 新增:当前商品信息(内存,随页面变化,不落盘) ---- // itemId -> {sellerId, title, price, ts} var curItems = {}; // ---- v1.4.5 新增:列表页商品映射(内存) ---- // itemId -> {sellerId, title, price},来自列表/搜索接口捕获,用于列表页红框高亮 var pageItems = {}; var hlTimer = null; // ============================================================ // 调试信息弹窗 // ============================================================ function showDebugInfo() { try { var url = location.href; var profile = isProfile(url); var itemPage = isItemPage(url); var sid = getUserId(url); var itemId = itemPage ? getItemId(url) : null; var cur = itemId ? curItems[String(itemId)] : null; var fav = FavStore.get(); var vis = VisitStore.get(); var fs = sid ? FavStore.check(sid) : null; var vr = sid ? VisitStore.check(sid) : null; var itemSid = cur ? cur.sellerId : null; var itemFs = itemSid ? FavStore.check(itemSid) : null; var itemInFav = itemId ? isItemInFav(itemId) : false; var listPage = isSearchPage(url); var hlCount = listPage ? document.querySelectorAll('[data-xy-marked="1"]').length : 0; var msg = '【脚本调试信息 v1.4.13】\n\n'; msg += '当前URL: ' + url.substring(0, 120) + '\n'; msg += '是否卖家主页: ' + (profile ? '是 ✓' : '否 ✗') + '\n'; msg += '是否商品详情页: ' + (itemPage ? '是 ✓' : '否 ✗') + '\n'; msg += '是否列表页(搜索/首页): ' + (listPage ? '是 ✓' : '否 ✗') + '\n'; msg += '列表页红框数: ' + (listPage ? hlCount : '-') + '\n'; msg += '列表页商品映射: ' + Object.keys(pageItems).length + ' 条\n'; // v1.4.16:诊断映射和别名 var iuMap = ItemUserStore.get(); var saMap = SidAliasStore.get(); msg += 'ItemUserStore(itemId→userId): ' + Object.keys(iuMap).length + ' 条\n'; msg += 'SidAliasStore(sellerId→userId): ' + Object.keys(saMap).length + ' 条\n'; var iuSample = Object.keys(iuMap).slice(0, 3).map(function (k) { return k + '→' + iuMap[k]; }); if (iuSample.length) msg += ' 映射样例: ' + iuSample.join(', ') + '\n'; var saSample = Object.keys(saMap).slice(0, 3).map(function (k) { return k + '→' + saMap[k]; }); if (saSample.length) msg += ' 别名样例: ' + saSample.join(', ') + '\n'; var piKeys = Object.keys(pageItems).slice(0, 3); if (piKeys.length) { msg += ' pageItems 样例: '; msg += piKeys.map(function (k) { var v = pageItems[k]; return k + '(sid=' + v.sellerId + ',raw=' + (v.rawSellerId || '-') + ')'; }).join(' | ') + '\n'; } msg += '提取sellerId: ' + (sid || '无') + '\n'; msg += '提取itemId: ' + (itemId || '无') + '\n\n'; msg += '收藏索引: ' + Object.keys(fav.data).length + ' 个卖家\n'; msg += '访问历史: ' + Object.keys(vis).length + ' 个卖家(无上限)\n'; // v1.4.13:批量任务已停用,不再显示 // msg += '批量任务已处理商家: ' + Object.keys(BatchDoneStore.get()).length + ' 个\n\n'; if (profile && sid) { msg += '当前卖家收藏: ' + (fs ? '有 ' + fs.count + ' 件 ✓' : '无 ✗') + '\n'; msg += '当前卖家访问: ' + (vr ? '第 ' + vr.visitCount + ' 次' : '首次(未记录)') + '\n'; } if (itemPage && itemId) { msg += '商品页卖家ID: ' + (itemSid || '未识别到') + '\n'; msg += '商品页卖家收藏: ' + (itemFs ? '有 ' + itemFs.count + ' 件 ✓' : '无 ✗') + '\n'; msg += '该商品已在收藏夹: ' + (itemInFav ? '是 ✓' : '否 ✗') + '\n'; if (cur) msg += '当前商品: ¥' + (cur.price || '?') + ' · ' + (cur.title || itemId) + '\n'; } if (initError) msg += '\n初始化错误: ' + initError.message + '\n'; msg += '\n--- 排查指引 ---\n'; msg += '1. 右下角橙色点 = 脚本正在初始化\n'; msg += '2. 绿色点 = 初始化完成,普通页面\n'; msg += '3. 蓝色点 = 已识别到卖家主页/商品详情页/列表页\n'; // v1.4.13:批量任务已停用 // msg += '4. 红色点 = 批量收藏任务正在运行\n'; msg += '4. 列表页命中卡片左上角常驻角标:橙⭐已收藏 / 蓝👀已浏览\n'; msg += '5. 首次进入卖家主页不会弹"再次访问"(第二次才弹)\n'; msg += '6. 收藏提醒需先访问"我的收藏"页面采集数据\n'; msg += '7. 商品详情页需等卖家信息加载完,脚本会自动重试\n'; msg += '8. 列表页/商品详情页「相关推荐」命中卡片左上角常驻角标:橙⭐已收藏 / 蓝👀已浏览;悬浮仍显示红框\n'; msg += '9. F12控制台筛选"闲鱼收藏提醒"看详细日志(含DOM/接口提取卖家数统计)'; alert(msg); } catch (e) { alert('调试信息生成失败: ' + e.message); } } // ============================================================ // 提醒框 UI // ============================================================ function injectReminderStyles() { try { if (document.getElementById('xy-reminder-style')) return; var s = document.createElement('style'); s.id = 'xy-reminder-style'; s.textContent = '@keyframes xy-in{from{opacity:0;transform:translateY(-15px)}to{opacity:1;transform:translateY(0)}}@keyframes xy-out{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(-15px)}}.xy-card{pointer-events:auto;border-radius:12px;padding:12px 18px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;font-size:14px;color:#333;width:100%;box-sizing:border-box;animation:xy-in .3s ease-out;box-shadow:0 6px 20px rgba(0,0,0,.15);}.xy-card.xy-close{animation:xy-out .4s ease-in forwards;}'; document.head.appendChild(s); } catch (e) { warn('注入样式失败', e); } } // v1.4.7 新增:列表页命中卡片的 hover 红框样式 // v1.4.12 新增:命中卡片左上角常驻角标(⭐已收藏橙 / 👀已浏览蓝),不用悬浮直接可见 function injectHoverStyles() { try { if (document.getElementById('xy-hover-style')) return; var s = document.createElement('style'); s.id = 'xy-hover-style'; s.textContent = '.xy-hit-card{cursor:pointer;position:relative!important;}.xy-hit-card.xy-hit-card:hover{outline:3px solid #ff3b30!important;outline-offset:-3px!important;box-shadow:0 0 14px rgba(255,59,48,.45)!important;z-index:5;transition:none!important;}.xy-badge-box{position:absolute!important;top:6px!important;left:6px!important;z-index:9!important;display:flex!important;gap:4px!important;pointer-events:none!important;}.xy-badge{display:inline-block!important;padding:2px 8px!important;border-radius:10px!important;font-size:11px!important;line-height:1.5!important;color:#fff!important;box-shadow:0 1px 4px rgba(0,0,0,.3)!important;white-space:nowrap!important;}.xy-badge-fav{background:#ff9500!important;}.xy-badge-vis{background:#4a90d9!important;}'; document.head.appendChild(s); } catch (e) { warn('注入hover样式失败', e); } } function getContainer() { try { var c = document.getElementById('xy-reminder-container'); if (c) return c; if (!document.body) return null; c = document.createElement('div'); c.id = 'xy-reminder-container'; c.style.cssText = 'position:fixed!important;top:70px!important;left:50%!important;transform:translateX(-50%)!important;z-index:2147483646!important;display:flex!important;flex-direction:column!important;gap:10px!important;align-items:center!important;width:calc(100% - 40px)!important;max-width:440px!important;pointer-events:none!important;'; document.body.appendChild(c); return c; } catch (e) { warn('创建容器失败', e); return null; } } function showCard(type, html) { try { injectReminderStyles(); var c = getContainer(); if (!c) { warn('容器不存在,延迟重试'); setTimeout(function () { showCard(type, html); }, 500); return; } var old = c.querySelector('.xy-card[data-type="' + type + '"]'); if (old) old.remove(); var card = document.createElement('div'); card.className = 'xy-card'; card.dataset.type = type; if (type === 'fav') { card.style.background = 'linear-gradient(135deg,#fff5e6,#ffe8cc)'; card.style.border = '2px solid #ff9500'; } else { card.style.background = 'linear-gradient(135deg,#e6f0ff,#cce0ff)'; card.style.border = '2px solid #4a90d9'; } var icon = type === 'fav' ? '⭐' : '👀'; card.innerHTML = '
' + icon + '
' + html + '
×
'; c.appendChild(card); card.querySelector('.xy-x').addEventListener('click', function () { removeCard(card); }); if (CONFIG.TOAST_DURATION > 0) setTimeout(function () { removeCard(card); }, CONFIG.TOAST_DURATION); } catch (e) { warn('显示提醒失败', e); } } function removeCard(card) { try { if (!card || card.classList.contains('xy-close')) return; card.classList.add('xy-close'); setTimeout(function () { if (card.parentNode) card.parentNode.removeChild(card); var c = document.getElementById('xy-reminder-container'); if (c && c.children.length === 0) c.remove(); }, 400); } catch (e) { } } function clearCards() { try { var c = document.getElementById('xy-reminder-container'); if (c) c.remove(); } catch (e) { } } // ============================================================ // 收藏数据采集 // ============================================================ function extractSellers(text) { var sellers = {}; try { var json = JSON.parse(text); function walk(o, d) { if (!o || d > 20) return; if (Array.isArray(o)) { for (var i = 0; i < o.length; i++) walk(o[i], d + 1); return; } if (typeof o !== 'object') return; var sid = o.userId || o.sellerId || o.seller_id || o.aliPayUserId || o.ownerId || o.sellerUserId; var iid = o.itemId || o.item_id || o.feedId || o.targetId || o.id; if (sid && iid && !isNaN(iid)) { if (!sellers[sid]) sellers[sid] = { count: 0, items: [] }; var exists = sellers[sid].items.some(function (x) { return x.id === String(iid); }); if (!exists) { sellers[sid].count++; if (sellers[sid].items.length < 10) { sellers[sid].items.push({ id: String(iid), title: o.title || o.itemTitle || '', price: o.price || '' }); } } } for (var k in o) { if (k !== '__proto__' && k !== 'constructor') walk(o[k], d + 1); } } walk(json, 0); } catch (e) { } return sellers; } // ---- v1.4.0 新增:从商品接口响应中提取商品信息(sellerId/itemId/标题/价格) ---- function extractItemInfo(text) { try { var json = JSON.parse(text); function walk(o, d) { if (!o || d > 25) return; if (Array.isArray(o)) { for (var i = 0; i < o.length; i++) walk(o[i], d + 1); return; } if (typeof o !== 'object') return; var sid = o.userId || o.sellerId || o.seller_id || o.aliPayUserId || o.ownerId || o.sellerUserId; var iid = o.itemId || o.item_id || o.feedId || o.targetId || null; var title = o.title || o.itemTitle || ''; var price = o.price || o.priceText || o.soldPrice || ''; if (sid && iid) { curItems[String(iid)] = { sellerId: String(sid), title: String(title), price: String(price), ts: Date.now() }; // v1.4.15:只有接口返回的 iid 等于当前商品页 URL 的 itemId 才记录映射(避免推荐商品污染) try { var curIid = getItemId(location.href); if (curIid && String(iid) === String(curIid)) { ItemUserStore.record(iid, sid); } } catch (e) { } } for (var k in o) { if (k !== '__proto__' && k !== 'constructor') walk(o[k], d + 1); } } walk(json, 0); } catch (e) { } } // ---- v1.4.5 新增:从列表/搜索接口响应中提取商品 sellerId 映射,用于列表页红框高亮 ---- // v1.5.2:先做字符串预筛,避免对没有商品字段的响应做整段 JSON.parse function extractPageItems(text) { try { if (!text || text.length > 3000000) return; if (text.indexOf('itemId') < 0 && text.indexOf('item_id') < 0) return; var json = JSON.parse(text); function walk(o, d) { if (!o || d > 25) return; if (Array.isArray(o)) { for (var i = 0; i < o.length; i++) walk(o[i], d + 1); return; } if (typeof o !== 'object') return; var uid = o.userId || o.user_id || o.aliPayUserId || o.ownerId || o.sellerUserId; var sid = o.sellerId || o.seller_id; // v1.4.17:搜索接口的 userId 不是卖家主页 ID,不在此处建立别名 var iid = o.itemId || o.item_id || o.feedId || o.targetId || null; var title = o.title || o.itemTitle || ''; var price = o.price || o.priceText || o.soldPrice || ''; // v1.4.19:优先加密原值 sid,保留 rawSellerId 用于别名转换 var effectiveSid = sid || uid; if (effectiveSid && iid) { pageItems[String(iid)] = { sellerId: String(effectiveSid), rawSellerId: sid ? String(sid) : String(effectiveSid), userId: uid ? String(uid) : null, title: String(title), price: String(price) }; } for (var k in o) { if (k !== '__proto__' && k !== 'constructor') walk(o[k], d + 1); } } walk(json, 0); savePageItemsSoon(); scheduleHighlight(); } catch (e) { } } // v1.5.2:pageItems 每次接口返回都全量 JSON.stringify 写 sessionStorage,越滚越大 // 改为 2 秒合并写一次,并把条目上限压到 4000 条(超出丢最旧的) var pageItemsSaveTimer = null; function savePageItemsSoon() { if (pageItemsSaveTimer) return; pageItemsSaveTimer = setTimeout(function () { pageItemsSaveTimer = null; try { var keys = Object.keys(pageItems); if (keys.length > 4000) { for (var i = 0; i < keys.length - 4000; i++) delete pageItems[keys[i]]; } sessionStorage.setItem('xy_page_items', JSON.stringify(pageItems)); } catch (e) { } }, 2000); } // v1.5.2:高亮最小间隔 —— 避免 MutationObserver/补标定时器把全页扫描打成高频循环 var lastHlRunAt = 0; // 防抖:接口多次返回时合并高亮动作,等 DOM 渲染完再执行 function scheduleHighlight() { try { if (hlTimer) clearTimeout(hlTimer); hlTimer = setTimeout(function () { hlTimer = null; var now = Date.now(); if (now - lastHlRunAt < 1200) return; // 1.2 秒内不重复全扫 lastHlRunAt = now; highlightMatchedCards(); }, 600); } catch (e) { } } function isFavApi(url) { if (!url) return false; var l = url.toLowerCase(); return ['favorite', 'favourite', 'collect', 'collection', 'favlist', 'fav_list', 'myfav', 'my_fav', 'idle.fav', 'idlefav', 'fav.item'].some(function (k) { return l.indexOf(k) >= 0; }); } // ---- v1.4.0 新增:识别商品详情接口 ---- function isItemApi(url) { if (!url) return false; var l = String(url).toLowerCase(); if (l.indexOf('favorite') >= 0 || l.indexOf('favlist') >= 0 || l.indexOf('fav_list') >= 0 || l.indexOf('my_fav') >= 0) return false; return (l.indexOf('/item') >= 0 || l.indexOf('item.detail') >= 0 || l.indexOf('itemdetail') >= 0 || l.indexOf('item/detail') >= 0) && (l.indexOf('detail') >= 0 || l.indexOf('sdk') >= 0 || l.indexOf('mtop') >= 0 || l.indexOf('id=') >= 0); } // v1.5.2:把大 JSON 解析挪到空闲帧,避免滚动加载时卡住渲染 function deferIdle(fn) { try { if (typeof requestIdleCallback === 'function') { requestIdleCallback(fn, { timeout: 2000 }); return; } } catch (e) { } setTimeout(fn, 0); } function hookXHR() { try { var oOpen = XMLHttpRequest.prototype.open; var oSend = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function (m, u) { this._xyu = u; return oOpen.apply(this, arguments); }; XMLHttpRequest.prototype.send = function () { var self = this; this.addEventListener('load', function () { try { var u = String(self._xyu || ''); if (isFavApi(u) && self.responseText) { log('XHR捕获收藏接口:', u.substring(0, 120)); var s = extractSellers(self.responseText); if (Object.keys(s).length) { var ex = FavStore.get().data; for (var k in s) ex[k] = s[k]; FavStore.set(ex); } } else if (isItemApi(u) && self.responseText) { log('XHR捕获商品接口:', u.substring(0, 120)); extractItemInfo(self.responseText); } else if (isListApi(u) && self.responseText) { log('XHR捕获列表接口:', u.substring(0, 120)); var xt = self.responseText; deferIdle(function () { extractPageItems(xt); }); } } catch (e) { warn('XHR处理失败', e); } }); return oSend.apply(this, arguments); }; log('XHR hook 完成'); } catch (e) { warn('XHR hook 失败', e); } } function hookFetch() { try { if (typeof window.fetch !== 'function') { log('无 fetch,跳过'); return; } var o = window.fetch; window.fetch = function () { var args = arguments; return o.apply(this, args).then(function (resp) { try { var url = typeof args[0] === 'string' ? args[0] : (args[0] && args[0].url); if (isFavApi(url)) { log('fetch捕获收藏接口:', String(url).substring(0, 120)); resp.clone().text().then(function (t) { var s = extractSellers(t); if (Object.keys(s).length) { var ex = FavStore.get().data; for (var k in s) ex[k] = s[k]; FavStore.set(ex); } }).catch(function () { }); } else if (isItemApi(url)) { log('fetch捕获商品接口:', String(url).substring(0, 120)); resp.clone().text().then(function (t) { extractItemInfo(t); }).catch(function () { }); } else if (isListApi(url)) { log('fetch捕获列表接口:', String(url).substring(0, 120)); resp.clone().text().then(function (t) { deferIdle(function () { extractPageItems(t); }); }).catch(function () { }); } } catch (e) { warn('fetch处理失败', e); } return resp; }); }; log('fetch hook 完成'); } catch (e) { warn('fetch处理失败', e); } } // ============================================================ // 页面检测 // ============================================================ // ---- v1.4.0 新增:从商品页 DOM 中提取卖家ID(点卖家头像/名字通常跳到 /personal?userId=xxx) ---- function findSellerIdFromLinks() { try { var links = document.querySelectorAll('a[href*="/personal"]'); for (var i = 0; i < links.length; i++) { var h = links[i].getAttribute('href') || ''; var m = h.match(/[?&](?:userId|user_id)=([^&#]+)/); if (m) return decodeURIComponent(m[1]); } var links2 = document.querySelectorAll('a[href*="userId="], a[href*="user_id="]'); for (var j = 0; j < links2.length; j++) { var h2 = links2[j].getAttribute('href') || ''; var m2 = h2.match(/[?&](?:userId|user_id)=([^&#]+)/); if (m2) return decodeURIComponent(m2[1]); } } catch (e) { } return null; } function findSellerIdFromScripts() { try { var scripts = document.querySelectorAll('script'); for (var j = 0; j < scripts.length && j < 40; j++) { var t = scripts[j].textContent || ''; var m = t.match(/"(?:sellerId|sellerUserId|ownerId)"\s*:\s*"?(\d{5,})/); if (m) return m[1]; } } catch (e) { } return null; } // ---- v1.4.0 新增:商品是否已在收藏夹(按商品ID匹配) ---- function isItemInFav(itemId) { try { var id = String(itemId); var data = FavStore.get().data; for (var k in data) { var arr = data[k].items || []; for (var i = 0; i < arr.length; i++) { if (String(arr[i].id) === id) return true; } } } catch (e) { } return false; } // ---- v1.4.5 新增:列表页红框高亮 ---- // v1.4.8 精准定位:从商品链接向上找,第一个同时满足 // ①只含1个商品链接 ②包含图片 ③包含文本(标题/价格) 的元素 = 单个商品卡片 function findItemCardEl(link) { try { var el = link; for (var i = 0; i < 8; i++) { if (!el || el === document.body) break; var itemLinks = el.querySelectorAll ? el.querySelectorAll('a[href*="/item"], a[href*="id="]') : []; var hasImg = el.querySelector ? el.querySelector('img') : null; var hasText = el.textContent && el.textContent.trim().length > 5; if (itemLinks.length <= 1 && hasImg && hasText) return el; el = el.parentElement; } // 兜底:返回 link 的父元素(非 body) return (link.parentElement && link.parentElement !== document.body) ? link.parentElement : link; } catch (e) { return link; } } // v1.4.6 新增:从卡片 DOM 内直接提取卖家ID(不依赖接口) function extractSellerIdFromCard(card) { try { if (!card || !card.querySelectorAll) return null; var links = card.querySelectorAll('a[href*="/personal"], a[href*="userId="], a[href*="user_id="]'); for (var i = 0; i < links.length; i++) { var h = links[i].getAttribute('href') || ''; var m = h.match(/[?&](?:userId|user_id)=([^&#]+)/); if (m) return decodeURIComponent(m[1]); } var ds = card.dataset || {}; if (ds.sellerId || ds.userId || ds.sellerid) return String(ds.sellerId || ds.userId || ds.sellerid); } catch (e) { } return null; } // 命中判断:商品本身在收藏夹,或该商品卖家在收藏夹 / 访问过其主页 function isItemMatched(itemId, sellerId) { try { if (isItemInFav(itemId)) return true; if (sellerId) { if (FavStore.check(sellerId)) return true; if (VisitStore.check(sellerId)) return true; // v1.5.0:共享数据判断 if (SharedStore.isSharedFav(sellerId)) return true; if (SharedStore.isSharedVisit(sellerId)) return true; } } catch (e) { } return false; } // v1.5.1:一个卡片可能对应多个卖家ID来源,逐个收集用于匹配 // 顺序:DOM 里的明文 userId(最可信)→ itemId 反查(主页/详情页采集)→ 加密 sellerId 的别名 → 加密原值(仅供共享库) function collectSellerCandidates(itemId, card, encSid) { var out = []; function push(v) { if (!v) return; v = String(v); if (out.indexOf(v) < 0) out.push(v); } try { push(extractSellerIdFromCard(card)); } catch (e) { } try { push(ItemUserStore.getUserId(itemId)); } catch (e) { } try { if (encSid) push(SidAliasStore.resolve(encSid)); } catch (e) { } try { push(encSid); } catch (e) { } return out; } // v1.5.1:列表页(搜索/首页)+ 商品详情页底部“相关推荐”卡片统一高亮 // 只认 itemId 匹配,向上找含 img 的父级挂载角标 function highlightMatchedCards() { try { var onItem = isItemPage(location.href); var onSearch = isSearchPage(location.href); if (!onItem && !onSearch) return; // 详情页也要跑,供底部相关推荐卡片使用 var curId = onItem ? getItemId(location.href) : null; injectHoverStyles(); var links = document.querySelectorAll('a[href*="/item"], a[href*="id="]'); var total = 0, favHit = 0, visitHit = 0; var t0 = (window.performance && performance.now) ? performance.now() : Date.now(); var done = []; // 同一张卡可能被多个链接命中,只处理一次 var favData = FavStore.get().data; var favItemIds = {}; Object.keys(favData).forEach(function (k) { (favData[k].items || []).forEach(function (it) { favItemIds[String(it.id)] = true; }); }); for (var i = 0; i < links.length; i++) { var href = links[i].getAttribute('href') || ''; var itemId = getItemIdFromHref(href); if (!itemId) continue; // 详情页的主商品本身不重复标记(顶部已有提醒框) if (onItem && curId && String(itemId) === String(curId)) continue; total++; var card = links[i]; for (var j = 0; j < 3; j++) { if (!card || card === document.body) break; if (card.querySelector('img')) break; card = card.parentElement; } if (!card) continue; if (done.indexOf(card) >= 0) continue; done.push(card); var info = pageItems[String(itemId)] || null; var encSid = info ? (info.rawSellerId || info.sellerId) : null; var candidates = collectSellerCandidates(itemId, card, encSid); // 商品级:itemId 精确命中收藏 var isFavItem = !!favItemIds[String(itemId)]; // 卖家级:任一候选ID在收藏/访问历史/共享库里 var isFavSeller = false, isVisit = false, sharedFavCnt = 0, sharedVisitCnt = 0; for (var c = 0; c < candidates.length; c++) { var cand = candidates[c]; if (!isFavSeller && FavStore.check(cand)) isFavSeller = true; if (!isVisit && VisitStore.check(cand)) isVisit = true; var fc = SharedStore.getSharedFavCount(cand); var vc = SharedStore.getSharedVisitCount(cand); if (fc > sharedFavCnt) sharedFavCnt = fc; if (vc > sharedVisitCnt) sharedVisitCnt = vc; } var isFav = isFavItem || isFavSeller; var isSharedFav = !isFav && sharedFavCnt > 0; var isSharedVisit = !isFav && !isVisit && sharedVisitCnt > 0; // v1.5.2:之前命中过、现在不再命中的卡,把旧角标清掉,避免残留 if (!isFav && !isVisit && !isSharedFav && !isSharedVisit) { if (card.dataset.xySig) { delete card.dataset.xySig; card.classList.remove('xy-hit-card'); var stale = card.querySelector('.xy-badge-box'); if (stale) stale.remove(); } continue; } if (isFav) favHit++; if (isVisit) visitHit++; // v1.5.2:命中状态没变化就跳过 DOM 重建 // 原来每次高亮都删角标再重建,MutationObserver 又因此再次触发高亮 → 死循环空转 var sig = (isFav ? 'F' : '') + (isVisit ? 'V' : '') + (isSharedFav ? 'S' + sharedFavCnt : ''); if (card.dataset.xySig === sig && card.querySelector('.xy-badge-box')) continue; card.dataset.xySig = sig; var oldBadge = card.querySelector('.xy-badge-box'); if (oldBadge) oldBadge.remove(); if (!card.classList.contains('xy-hit-card')) { card.classList.add('xy-hit-card'); card.dataset.xyMarked = '1'; } var badgeBox = document.createElement('div'); badgeBox.className = 'xy-badge-box'; if (isFav) { var b1 = document.createElement('span'); b1.className = 'xy-badge xy-badge-fav'; b1.textContent = '⭐已收藏'; badgeBox.appendChild(b1); } // v1.5.0:共享收藏徽章 if (isSharedFav) { var b1s = document.createElement('span'); b1s.className = 'xy-badge xy-badge-shared'; b1s.textContent = '👥' + sharedFavCnt + '人收藏'; b1s.style.background = 'rgba(33,150,243,0.9)'; badgeBox.appendChild(b1s); } if (isVisit) { var b2 = document.createElement('span'); b2.className = 'xy-badge xy-badge-vis'; b2.textContent = '👀已浏览'; badgeBox.appendChild(b2); } card.appendChild(badgeBox); } var t1 = (window.performance && performance.now) ? performance.now() : Date.now(); log((onItem ? '详情页相关推荐高亮' : '列表页高亮') + ': 共' + total + '件, 已收藏' + favHit + ', 已浏览' + visitHit + ' | 收藏商品' + Object.keys(favItemIds).length + '个, itemId映射' + Object.keys(ItemUserStore.get()).length + '条' + ' | 耗时' + Math.round(t1 - t0) + 'ms'); } catch (e) { warn('卡片高亮失败', e); } } // ---- v1.4.5 新增:列表页检测(含重试,等页面商品渲染完成) ---- // v1.4.6 新增:MutationObserver 监听新卡片出现(翻页/加载更多时自动标记) var moObserver = null; function setupMutationObserver() { try { if (moObserver) return; moObserver = new MutationObserver(function (mutations) { var need = false; for (var i = 0; i < mutations.length; i++) { var nodes = mutations[i].addedNodes; if (!nodes || !nodes.length) continue; // v1.5.2:忽略脚本自己插入的角标节点,否则「加角标→触发观察→重扫→再加角标」会无限循环 for (var j = 0; j < nodes.length; j++) { var n = nodes[j]; if (!n || n.nodeType !== 1) continue; if (n.id && String(n.id).indexOf('xy-') === 0) continue; if (String(n.className || '').indexOf('xy-') >= 0) continue; need = true; break; } if (need) break; } if (need) scheduleHighlight(); }); moObserver.observe(document.body, { childList: true, subtree: true }); log('MutationObserver 已启动'); } catch (e) { warn('MutationObserver 启动失败', e); } } function checkSearchPage(attempt) { try { if (attempt === 0) { // v1.4.14:从 sessionStorage 恢复上次捕获的商品映射,不再直接清空 try { var raw = sessionStorage.getItem('xy_page_items'); if (raw) { var p = JSON.parse(raw); if (p && typeof p === 'object') pageItems = p; } } catch (e) { } if (!pageItems) pageItems = {}; } log('===== 列表页检测 ====='); setStatus('#2196f3', '列表页:标记收藏/访问过卖家的商品(红框)\n点击查看调试信息'); setupMutationObserver(); highlightMatchedCards(); // v1.5.2:重试从 5 轮降到 3 轮(后面交给 MutationObserver 补标,不必一直全扫) if (attempt < 2) setTimeout(function () { checkSearchPage(attempt + 1); }, 1000); } catch (e) { warn('列表页检测失败', e); } } // ---- v1.4.0 新增:商品详情页检测(重试机制等待页面数据加载) ---- function checkItemPage(attempt) { try { var url = location.href; var itemId = getItemId(url); if (!itemId) { clearCards(); setStatus('#4caf50', '闲鱼收藏提醒运行中\n当前:商品页(未取到ID)'); return; } log('===== 商品详情页检测 ====='); log('URL:', url); log('itemId:', itemId); setStatus('#2196f3', '商品详情页:' + itemId + '\n点击查看调试信息'); // v1.5.1:详情页底部的“相关推荐/猜你喜欢”商品卡也标角标 + 悬浮红框 // 推荐位是异步渲染的,立即跑一次 + 多次补标,同时用 MutationObserver 兜住后续追加 try { injectHoverStyles(); setupMutationObserver(); scheduleHighlight(); // 只在首轮安排补标,避免重试轮次堆叠定时器 if (attempt === 0) { setTimeout(scheduleHighlight, 2500); setTimeout(scheduleHighlight, 6000); } } catch (e) { warn('详情页相关推荐标红初始化失败', e); } // 提取卖家:优先 DOM 链接(高置信),其次商品接口缓存 var sellerFromLink = findSellerIdFromLinks(); var sellerId = sellerFromLink; var cur = curItems[String(itemId)]; if (!sellerId && cur && cur.sellerId) sellerId = cur.sellerId; if (!sellerId) sellerId = findSellerIdFromScripts(); log('提取sellerId:', sellerId || '无'); // 卖家信息还没加载出来 → 延迟重试(商品页内容异步加载) if (!sellerId) { if (attempt < 6) { setTimeout(function () { checkItemPage(attempt + 1); }, 500); return; } log('多次尝试仍未提取到卖家信息,仅检查商品本身是否已收藏'); } // 1) 卖家相关提醒:再次访问(只读不计数)+ 收藏提醒 if (sellerId) { // v1.4.19:DOM明文userId优先,接口加密sellerId建立别名 var plainUid = findSellerIdFromLinks(); var curInfo = curItems[String(itemId)]; var encSid = curInfo ? curInfo.sellerId : null; if (plainUid && encSid && String(plainUid) !== String(encSid)) { SidAliasStore.record(encSid, plainUid); } var recordUid = plainUid || sellerId; ItemUserStore.record(itemId, recordUid); // 再次访问:只读访问历史,若之前进入过该卖家主页则弹提醒;不记录、不累计次数 var vr = VisitStore.check(sellerId); if (vr && vr.visitCount >= 1) { var rhtml = '
再次访问:你已进入该卖家主页 ' + vr.visitCount + ' 次
首次:' + timeAgo(vr.firstVisit) + ' · 上次:' + timeAgo(vr.lastVisit) + '
卖家ID: ' + sellerId + ' · 商品页不累计次数
'; showCard('revisit', rhtml); } var fi = FavStore.check(sellerId); if (fi) { var items = ''; if (fi.items && fi.items.length) { for (var i = 0; i < Math.min(3, fi.items.length); i++) { var it = fi.items[i]; items += '
¥' + (it.price || '?') + ' · ' + (it.title || '商品' + it.id) + '
'; } } var curLine = cur ? ('
当前商品:¥' + (cur.price || '?') + ' · ' + (cur.title || itemId) + '
') : ''; var fhtml = '
收藏提醒:该卖家有 ' + fi.count + ' 件商品在你的收藏中
' + curLine + items + '
卖家ID: ' + sellerId + ' · 收藏更新于 ' + new Date(FavStore.get().updatedAt).toLocaleString('zh-CN') + '
'; showCard('fav', fhtml); setStatus('#ff9500', '收藏提醒已触发(商品页)\n点击查看调试信息'); log('===== 商品页检测完成 ====='); return; } } // 2) 当前商品本身已在收藏夹 → 弹提醒 if (isItemInFav(itemId)) { var line = cur ? ('¥' + (cur.price || '?') + ' · ' + (cur.title || itemId)) : ('商品ID: ' + itemId); var fhtml2 = '
收藏提醒:该商品已在你的收藏夹中
' + line + '
商品ID: ' + itemId + '
'; showCard('fav', fhtml2); setStatus('#ff9500', '商品已收藏提醒已触发\n点击查看调试信息'); } else { setStatus('#4caf50', '商品页:该商品/卖家不在收藏中\n点击查看调试信息'); } log('===== 商品页检测完成 ====='); } catch (e) { warn('商品页检测失败:', e); setStatus('#f44336', '检测出错:' + e.message); } } // ---- 卖家主页检测(原逻辑) ---- // ---- v1.4.18:多选择器+HTML兜底采集主页商品 itemId ---- function collectProfileItemIds(sid, attempt) { attempt = attempt || 0; try { var ids = new Set(); var links = document.querySelectorAll('a[href]'); for (var i = 0; i < links.length; i++) { var h = links[i].getAttribute('href') || ''; var m = h.match(/[?&]id=(\d{5,})/); if (m) ids.add(m[1]); } var els = document.querySelectorAll('[data-id], [data-item-id], [data-itemid], [data-item]'); for (var j = 0; j < els.length; j++) { var ds = els[j].dataset || {}; var candidates = [ds.id, ds.itemId, ds.itemid, ds.item]; for (var c = 0; c < candidates.length; c++) { if (candidates[c] && /^\d{5,}$/.test(String(candidates[c]))) ids.add(String(candidates[c])); } } // v1.5.2:只有 DOM 一路都没采到才做整页 HTML 正则兜底(整页 innerHTML 很贵,不宜每轮都扫) if (ids.size === 0) { var html = document.documentElement.innerHTML || ''; var patterns = [ /"itemId"\s*:\s*"?(\d{5,})"?/g, /"item_id"\s*:\s*"?(\d{5,})"?/g, /"id"\s*:\s*"?(\d{5,})"?\s*,\s*"[^"]*title/g ]; for (var p = 0; p < patterns.length; p++) { var re = patterns[p], mm, max = 100; while ((mm = re.exec(html)) && max-- > 0) ids.add(mm[1]); } } if (ids.size > 0) { ItemUserStore.recordBatch(Array.from(ids), sid); log('主页商品采集成功(第' + attempt + '次):' + ids.size + ' 个 itemId → userId ' + sid); log(' 采集样例:', Array.from(ids).slice(0, 5).join(',')); } else { log('主页商品采集为空(第' + attempt + '次),继续轮询'); if (attempt < 6) setTimeout(function () { collectProfileItemIds(sid, attempt + 1); }, 1200); else warn('主页商品采集最终失败:主页可能完全异步渲染或选择器需再调'); } } catch (e) { warn('采集主页商品失败:', e); } } function checkProfilePage() { try { var url = location.href; var sid = getUserId(url); if (!sid) { warn('未提取到userId:', url); setStatus('#f44336', '错误:未提取到sellerId'); return; } log('===== 卖家主页检测 ====='); log('URL:', url); log('sellerId:', sid); setStatus('#2196f3', '卖家主页:' + sid + '\n点击查看调试信息'); // v1.4.16:多选择器+轮询采集主页商品 itemId setTimeout(function () { collectProfileItemIds(sid, 0); }, 800); // 访问历史 var vr = VisitStore.record(sid); log('访问:', vr.isRevisit ? '再次访问,第' + vr.visitCount + '次' : '首次访问'); if (vr.isRevisit) { var html = '
再次访问:这是你第 ' + vr.visitCount + ' 次进入该卖家主页
首次:' + timeAgo(vr.firstVisit) + ' · 上次:' + timeAgo(vr.lastVisit) + '
卖家ID: ' + sid + '
'; showCard('revisit', html); setStatus('#4a90d9', '再次访问提醒已触发\n点击查看调试信息'); } // 收藏 var fi = FavStore.check(sid); log('收藏:', fi ? '有' + fi.count + '件' : '无(或未采集)'); if (fi) { var items = ''; if (fi.items && fi.items.length) { for (var i = 0; i < Math.min(3, fi.items.length); i++) { var it = fi.items[i]; items += '
¥' + (it.price || '?') + ' · ' + (it.title || '商品' + it.id) + '
'; } } var fhtml = '
收藏提醒:该卖家有 ' + fi.count + ' 件商品在你的收藏中
' + items + '
卖家ID: ' + sid + ' · 收藏更新于 ' + new Date(FavStore.get().updatedAt).toLocaleString('zh-CN') + '
'; showCard('fav', fhtml); setStatus('#ff9500', '收藏提醒已触发\n点击查看调试信息'); } else if (FavStore.expired()) { log('收藏数据为空或过期,请访问"我的收藏"页面采集'); } log('===== 检测完成 ====='); } catch (e) { warn('页面检测失败:', e); setStatus('#f44336', '检测出错:' + e.message); } } // ============================================================ // v1.5.0:批量取消收藏面板(收藏页专用) // ============================================================ var BatchFavPanel = { init: false, running: false, paused: false, stoppedByUser: false, cancelled: 0, total: 0, failStreak: 0, currentInterval: 800, panel: null, panelStatus: null, panelCount: null, panelBarFill: null, panelInterval: null, chkPrescan: null, chkScroll: null, chkFollow: null, btnStart: null, btnPause: null, btnStop: null, CONFIG: { defaultInterval: 800, intervalMin: 200, intervalMax: 10000, verifyTimeout: 4000, scrollWait: 1200, maxFailStreak: 6, backoffFactor: 1.6, maxPrescanLoops: 80 }, LS: function (key, def) { try { var v = localStorage.getItem('xycf:' + key); return v === null ? def : JSON.parse(v); } catch (e) { return def; } }, LSS: function (key, v) { try { localStorage.setItem('xycf:' + key, JSON.stringify(v)); } catch (e) { } }, qsa: function (sel, root) { return Array.from((root || document).querySelectorAll(sel)); }, isVisible: function (el) { if (!el) return false; var r = el.getBoundingClientRect(); return r.width > 0 && r.height > 0; }, getCancelButtons: function () { var self = this; var TAGS = 'div,span,a,button,li,em'; var all = self.qsa(TAGS); return all.filter(function (e) { if ((e.textContent || '').trim() !== '取消收藏') return false; var hasInner = self.qsa(TAGS, e).some(function (c) { return (c.textContent || '').trim() === '取消收藏'; }); return !hasInner && self.isVisible(e); }); }, currentCount: function () { return this.getCancelButtons().length; }, sleep: function (ms) { return new Promise(function (r) { setTimeout(r, ms); }); }, scrollBottom: function () { window.scrollTo(0, document.body.scrollHeight); window.scrollTo(0, document.documentElement.scrollHeight); }, scrollTop: function () { window.scrollTo(0, 0); }, readInterval: function () { var v = parseInt(this.panelInterval.value, 10); if (isNaN(v)) return this.CONFIG.defaultInterval; return Math.min(this.CONFIG.maxInterval, Math.max(this.CONFIG.intervalMin, v)); }, currentTitle: function (el) { try { var card = el.closest('[class*="feeds-item-wrap"], [class*="feedsItem"], [class*="item-card"]'); var t = card && card.querySelector('[class*="main-title"], [class*="title"], [class*="Title"]'); var s = (t ? t.textContent : (card ? card.textContent : '')).trim(); return s.length > 18 ? s.slice(0, 18) + '…' : s; } catch (e) { return ''; } }, build: function () { if (this.init) return; var self = this; var panel = document.createElement('div'); panel.id = 'xycf-panel'; panel.style.cssText = 'position:fixed;right:16px;bottom:60px;z-index:2147483647;width:264px;' + 'background:#1f2329;color:#f5f6f7;border-radius:10px;box-shadow:0 6px 24px rgba(0,0,0,.35);' + 'font-family:"Microsoft YaHei",system-ui,sans-serif;font-size:12px;line-height:1.6;padding:0;' + 'box-sizing:border-box;user-select:none;'; var head = document.createElement('div'); head.style.cssText = 'display:flex;align-items:center;justify-content:space-between;padding:8px 12px;border-bottom:1px solid #2c3138;'; var title = document.createElement('span'); title.textContent = '批量取消收藏'; title.style.cssText = 'font-size:13px;font-weight:700;color:#ff8c00;'; var btnFold = document.createElement('button'); btnFold.textContent = '收起'; btnFold.style.cssText = 'border:none;background:transparent;color:#8a919b;font-size:11px;cursor:pointer;'; var bodyEl = document.createElement('div'); bodyEl.style.cssText = 'padding:10px 12px 12px;'; btnFold.addEventListener('click', function () { var folded = bodyEl.style.display !== 'none'; bodyEl.style.display = folded ? 'none' : ''; btnFold.textContent = folded ? '展开' : '收起'; }); head.appendChild(title); head.appendChild(btnFold); var panelBar = document.createElement('div'); panelBar.style.cssText = 'height:8px;background:#343a42;border-radius:4px;overflow:hidden;margin-bottom:6px;'; self.panelBarFill = document.createElement('div'); self.panelBarFill.style.cssText = 'height:100%;width:0%;background:linear-gradient(90deg,#ff9a3d,#ff6a00);transition:width .3s;'; panelBar.appendChild(self.panelBarFill); bodyEl.appendChild(panelBar); self.panelCount = document.createElement('div'); self.panelCount.style.cssText = 'margin-bottom:6px;color:#e8eaed;font-weight:600;'; self.panelStatus = document.createElement('div'); self.panelStatus.style.cssText = 'margin-bottom:8px;color:#9aa0a8;min-height:18px;word-break:break-all;'; bodyEl.appendChild(self.panelCount); bodyEl.appendChild(self.panelStatus); var row1 = document.createElement('div'); row1.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:6px;'; var lab1 = document.createElement('label'); lab1.textContent = '点击间隔(ms)'; self.panelInterval = document.createElement('input'); self.panelInterval.type = 'number'; self.panelInterval.value = String(self.LS('interval', self.CONFIG.defaultInterval)); self.panelInterval.min = String(self.CONFIG.intervalMin); self.panelInterval.max = String(self.CONFIG.maxInterval); self.panelInterval.style.cssText = 'width:78px;padding:3px 6px;border:1px solid #3a3f47;border-radius:4px;background:#111418;color:#fff;'; row1.appendChild(lab1); row1.appendChild(self.panelInterval); bodyEl.appendChild(row1); function mkRow(chk, key, label, def) { chk.type = 'checkbox'; chk.checked = self.LS(key, def !== false); chk.addEventListener('change', function () { self.LSS(key, chk.checked); }); var row = document.createElement('div'); row.style.cssText = 'display:flex;align-items:center;gap:6px;margin-bottom:4px;'; var l = document.createElement('label'); l.textContent = label; row.appendChild(chk); row.appendChild(l); return row; } self.chkPrescan = document.createElement('input'); bodyEl.appendChild(mkRow(self.chkPrescan, 'prescan', '先统计总数,再从头处理', true)); self.chkScroll = document.createElement('input'); bodyEl.appendChild(mkRow(self.chkScroll, 'scroll', '自动滚动加载更多', true)); self.chkFollow = document.createElement('input'); bodyEl.appendChild(mkRow(self.chkFollow, 'follow', '跟随进度滚动显示', false)); var row3 = document.createElement('div'); row3.style.cssText = 'display:flex;gap:8px;margin-top:8px;'; function btnStyle(bg, fg) { return 'flex:1;padding:5px 0;border:none;border-radius:5px;background:' + bg + ';color:' + fg + ';font-size:12px;cursor:pointer;font-weight:600;'; } self.btnStart = document.createElement('button'); self.btnStart.textContent = '开始'; self.btnStart.style.cssText = btnStyle('#ff8c00', '#1a1205'); self.btnPause = document.createElement('button'); self.btnPause.textContent = '暂停'; self.btnPause.style.cssText = btnStyle('#3a3f47', '#fff'); self.btnStop = document.createElement('button'); self.btnStop.textContent = '停止'; self.btnStop.style.cssText = btnStyle('#3a3f47', '#fff'); self.btnPause.disabled = true; self.btnStop.disabled = true; row3.appendChild(self.btnStart); row3.appendChild(self.btnPause); row3.appendChild(self.btnStop); bodyEl.appendChild(row3); self.btnStart.addEventListener('click', function () { self.startRun(); }); self.btnPause.addEventListener('click', function () { self.togglePause(); }); self.btnStop.addEventListener('click', function () { self.stopRun(); }); var note = document.createElement('div'); note.textContent = '需已登录;失败会自动减速重试。'; note.style.cssText = 'margin-top:8px;color:#6d737b;font-size:11px;'; bodyEl.appendChild(note); panel.appendChild(head); panel.appendChild(bodyEl); document.body.appendChild(panel); self.panel = panel; self.init = true; self.updateUI(); self.setStatus('就绪。点击"开始"批量取消收藏'); }, setBusy: function (busy) { this.btnStart.disabled = busy; this.btnPause.disabled = !busy; this.btnStop.disabled = !busy; }, setStatus: function (t) { this.panelStatus.textContent = t; }, setProgress: function (pct) { this.panelBarFill.style.width = Math.max(0, Math.min(100, pct)) + '%'; }, updateUI: function () { this.panelCount.textContent = '已取消 ' + this.cancelled + ' / ' + (this.total || this.currentCount()); }, beep: function () { try { var ctx = new (window.AudioContext || window.webkitAudioContext)(); var o = ctx.createOscillator(); var g = ctx.createGain(); o.connect(g); g.connect(ctx.destination); o.frequency.value = 880; g.gain.value = 0.15; o.start(); setTimeout(function () { o.stop(); ctx.close(); }, 300); } catch (e) { } }, prescan: async function () { var self = this; var lastCount = -1; var stableRounds = 0; for (var i = 0; i < self.CONFIG.maxPrescanLoops; i++) { if (!self.running) return; self.scrollBottom(); await self.sleep(self.CONFIG.scrollWait); var c = self.currentCount(); self.setStatus('正在统计收藏总数…已加载 ' + c + ' 条'); if (c === lastCount) { stableRounds++; if (stableRounds >= 2) break; } else { stableRounds = 0; } lastCount = c; } self.total = Math.max(0, lastCount); self.scrollTop(); await self.sleep(400); }, waitForDecrease: function (before) { var self = this; var start = Date.now(); return new Promise(function (resolve) { (function poll() { if (!self.running) { resolve(true); return; } if (self.currentCount() < before) { resolve(true); return; } if (Date.now() - start > self.CONFIG.verifyTimeout) { resolve(false); return; } setTimeout(poll, 200); })(); }); }, processOne: async function () { var self = this; var btns = self.getCancelButtons(); if (!btns.length) return 'none'; var target = btns[0]; if (self.chkFollow.checked) { try { target.scrollIntoView({ block: 'nearest' }); } catch (e) { } } var t = self.currentTitle(target); if (t) self.setStatus('正在处理:' + t); var before = self.currentCount(); try { target.click(); } catch (e) { } var ok = await self.waitForDecrease(before); if (ok) { self.cancelled += Math.max(0, before - self.currentCount()); self.failStreak = 0; self.currentInterval = self.readInterval(); return 'ok'; } self.failStreak++; self.currentInterval = Math.min(self.CONFIG.maxInterval, Math.round(self.currentInterval * self.CONFIG.backoffFactor)); self.setStatus('点击未生效,减速至 ' + self.currentInterval + 'ms(连续 ' + self.failStreak + ' 次)'); return 'fail'; }, run: async function () { var self = this; self.running = true; self.paused = false; self.stoppedByUser = false; self.cancelled = 0; self.failStreak = 0; self.total = 0; self.currentInterval = self.readInterval(); self.setBusy(true); self.setProgress(0); self.updateUI(); var reason = ''; try { if (self.chkPrescan.checked) { await self.prescan(); if (!self.running) return; self.setStatus('统计完成,共 ' + self.total + ' 个收藏,开始处理…'); } else { self.total = self.currentCount(); } self.setProgress(0); self.updateUI(); while (self.running) { while (self.paused && self.running) { await self.sleep(200); } if (!self.running) break; var r = await self.processOne(); if (r === 'none') { if (self.total > self.cancelled && self.chkScroll.checked) { self.scrollBottom(); await self.sleep(self.CONFIG.scrollWait); if (self.currentCount() > 0) continue; } reason = '处理完成:共取消 ' + self.cancelled + ' 个收藏'; break; } if (r === 'fail' && self.failStreak >= self.CONFIG.maxFailStreak) { reason = '连续 ' + self.failStreak + ' 次未生效(可能触发风控),已自动停止'; break; } self.setProgress(self.total ? Math.round(self.cancelled / self.total * 100) : 100); self.updateUI(); await self.sleep(self.currentInterval); } } catch (e) { reason = '出错:' + (e && e.message ? e.message : e); } finally { self.running = false; self.paused = false; self.setBusy(false); if (self.stoppedByUser) reason = '已手动停止'; self.setStatus(reason + '(已取消 ' + self.cancelled + ' / ' + (self.total || self.currentCount()) + ')'); self.setProgress(self.total ? Math.round(self.cancelled / self.total * 100) : 0); self.updateUI(); if (self.total > 0 && self.cancelled >= self.total) self.beep(); } }, startRun: function () { if (!this.running) this.run(); }, togglePause: function () { if (!this.running) return; this.paused = !this.paused; this.btnPause.textContent = this.paused ? '继续' : '暂停'; if (this.paused) this.setStatus('已暂停(点击"继续"恢复)'); }, stopRun: function () { this.stoppedByUser = true; this.running = false; } }; function checkPage() { try { var url = location.href; if (isCollectionPage(url)) { BatchFavPanel.build(); setStatus('#4caf50', '收藏页:批量取消面板已加载\n点击右下角面板开始'); return; } if (isProfile(url)) { checkProfilePage(); return; } if (isItemPage(url)) { checkItemPage(0); return; } if (isSearchPage(url)) { checkSearchPage(0); return; } clearCards(); setStatus('#4caf50', '闲鱼收藏提醒运行中\n当前:普通页面\n点击查看调试信息'); } catch (e) { warn('页面检测失败:', e); setStatus('#f44336', '检测出错:' + e.message); } } // ============================================================ // SPA 路由监听 // ============================================================ function setupSpa() { try { var last = location.href; function trigger() { if (location.href !== last) { last = location.href; log('URL变化:', last); checkPage(); } } var op = history.pushState, or = history.replaceState; history.pushState = function () { var r = op.apply(this, arguments); setTimeout(trigger, 150); return r; }; history.replaceState = function () { var r = or.apply(this, arguments); setTimeout(trigger, 150); return r; }; window.addEventListener('popstate', function () { setTimeout(trigger, 150); }); setInterval(trigger, 1000); log('SPA监听已启动'); } catch (e) { warn('SPA监听失败', e); } } // ============================================================ // v1.4.9 新增:批量收藏任务模块(状态机持久化,整页跳转不丢任务) // ============================================================ var BATCH_CONFIG = { DONE_KEY: 'xianyu_batch_done_sellers', STATE_KEY: 'xianyu_batch_task_state', MINIMIZE_KEY: 'xianyu_batch_panel_min', MIN_DELAY: 3000, MAX_DELAY: 8000, MAX_TASK_COUNT: 12, MAX_CONSECUTIVE_FAIL: 3, WAIT_ELEMENT_TIMEOUT: 15000 }; var batchPanel = null; var batchLogBox = null; var batchStartBtn = null; // 任务状态持久化存储(跨整页刷新/跳转不丢) var BatchStateStore = { get: function () { try { var raw = GM_getValue(BATCH_CONFIG.STATE_KEY, null); if (!raw) return { running: false }; return typeof raw === 'string' ? JSON.parse(raw) : raw; } catch (e) { warn('读取批量任务状态失败', e); return { running: false }; } }, set: function (state) { try { GM_setValue(BATCH_CONFIG.STATE_KEY, JSON.stringify(state)); } catch (e) { warn('保存批量任务状态失败', e); } }, reset: function () { try { GM_deleteValue(BATCH_CONFIG.STATE_KEY); } catch (e) {} } }; // 已处理商家存储(商家级去重) var BatchDoneStore = { get: function () { try { var raw = GM_getValue(BATCH_CONFIG.DONE_KEY, "{}"); return typeof raw === "string" ? JSON.parse(raw) : raw; } catch (e) { warn("读取批量已处理商家失败", e); return {}; } }, set: function (obj) { try { GM_setValue(BATCH_CONFIG.DONE_KEY, JSON.stringify(obj)); } catch (e) { warn("保存批量已处理商家失败", e); } }, check: function (sellerId) { return !!this.get()[String(sellerId)]; }, markDone: function (sellerId) { var all = this.get(); all[String(sellerId)] = { ts: Date.now() }; this.set(all); }, clear: function () { try { GM_deleteValue(BATCH_CONFIG.DONE_KEY); } catch (e) {} } }; function batchRandomDelay() { return Math.floor(Math.random() * (BATCH_CONFIG.MAX_DELAY - BATCH_CONFIG.MIN_DELAY) + BATCH_CONFIG.MIN_DELAY); } // 批量日志(同时写UI和持久化state,刷新页面不丢日志) function batchLog(text) { log('[批量任务]', text); var state = BatchStateStore.get(); state.log = state.log || []; var t = new Date().toLocaleTimeString(); state.log.push('[' + t + '] ' + text); if (state.log.length > 30) state.log = state.log.slice(-30); BatchStateStore.set(state); if (batchLogBox) { batchLogBox.textContent = state.log.join('\n'); batchLogBox.scrollTop = batchLogBox.scrollHeight; } } // 创建批量控制面板(左下角) function createBatchPanel() { try { if (batchPanel) return; if (!document.body) { setTimeout(createBatchPanel, 500); return; } var st = document.createElement('style'); st.textContent = '#xy-batch-panel{position:fixed;bottom:40px;left:16px;z-index:2147483645;background:#fff;border:1px solid #ddd;border-radius:8px;padding:10px;width:300px;box-shadow:0 4px 12px rgba(0,0,0,.15);font-size:12px;font-family:inherit;}#xy-batch-log{height:100px;overflow:auto;background:#f7f7f7;padding:6px;margin:6px 0;border-radius:4px;font-family:monospace;font-size:11px;white-space:pre-wrap;}#xy-batch-panel input{width:100%;box-sizing:border-box;padding:6px;border:1px solid #ddd;border-radius:4px;margin-bottom:6px;font-size:12px;}.xy-btns{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:6px;}.xy-btns button{padding:4px 8px;font-size:11px;cursor:pointer;border:1px solid #ddd;border-radius:4px;background:#f5f5f5;}'; document.head.appendChild(st); batchPanel = document.createElement('div'); batchPanel.id = 'xy-batch-panel'; batchPanel.innerHTML = '
' + '批量任务 v1.4.12' + '—' + '
' + '
' + '' + '
' + '' + '' + '' + '
' + '
运行日志:
' + '
'; document.body.appendChild(batchPanel); batchLogBox = batchPanel.querySelector('#xy-batch-log'); batchStartBtn = batchPanel.querySelector('#xy-batch-start'); // 恢复历史日志 var state = BatchStateStore.get(); if (state.log && state.log.length) { batchLogBox.textContent = state.log.join('\n'); batchLogBox.scrollTop = batchLogBox.scrollHeight; } if (state.running) { batchStartBtn.disabled = true; setStatus('#f44336', '批量收藏任务运行中!\n请勿切换标签页/关闭页面'); } // 按钮绑定 batchPanel.querySelector('#xy-batch-stop').addEventListener('click', function () { var st = BatchStateStore.get(); st.running = false; BatchStateStore.set(st); batchLog('⏹️ 手动停止任务'); setStatus('#4caf50', '闲鱼收藏提醒运行中 ✓\n点击查看调试信息'); batchStartBtn.disabled = false; }); batchPanel.querySelector('#xy-batch-clear').addEventListener('click', function () { if (confirm('确认清空所有已处理商家记录?\n清空后下次任务会重复处理老商家。')) { BatchDoneStore.clear(); batchLog('🗑️ 已清空已处理商家黑名单'); } }); // 最小化/展开面板逻辑 var minBtn = batchPanel.querySelector('#xy-batch-min'); var bodyBox = batchPanel.querySelector('#xy-batch-body'); function applyMinState(isMin) { if (isMin) { bodyBox.style.display = 'none'; batchPanel.style.width = '120px'; minBtn.textContent = '+'; minBtn.title = '展开面板'; } else { bodyBox.style.display = 'block'; batchPanel.style.width = '300px'; minBtn.textContent = '—'; minBtn.title = '最小化面板'; } GM_setValue(BATCH_CONFIG.MINIMIZE_KEY, isMin ? '1' : '0'); } minBtn.addEventListener('click', function () { var isMin = bodyBox.style.display !== 'none'; applyMinState(isMin); }); // 初始化恢复上次状态 var savedMin = GM_getValue(BATCH_CONFIG.MINIMIZE_KEY, '0'); applyMinState(savedMin === '1'); batchStartBtn.addEventListener('click', startBatchTask); batchLog('批量面板初始化完成 ✓'); // 初始化完后,检查是否需要从上次的任务断点继续 setTimeout(checkBatchState, 3000); } catch (e) { warn('批量面板创建失败', e); } } // 开始批量任务 function startBatchTask() { try { var st = BatchStateStore.get(); if (st.running) { alert('任务正在运行中!'); return; } var kw = batchPanel.querySelector('#xy-batch-kw').value.trim(); if (!kw) { alert('请先输入搜索关键词!'); return; } // 写入初始状态 var newState = { running: true, keyword: kw, step: 'wait_search', queue: [], current_sid: null, success: 0, fail: 0, consecutive_fail: 0, log: [] }; BatchStateStore.set(newState); batchStartBtn.disabled = true; setStatus('#f44336', '批量收藏任务运行中!\n请勿切换标签页/关闭页面'); batchLog('▶️ 任务启动,关键词:' + kw); // 跳转到搜索页 location.href = 'https://www.goofish.com/search?q=' + encodeURIComponent(kw); } catch (e) { warn('启动批量任务失败', e); batchLog('💥 启动失败:' + e.message); BatchStateStore.reset(); setStatus('#4caf50', '闲鱼收藏提醒运行中 ✓\n点击查看调试信息'); batchStartBtn.disabled = false; } } // 状态机:每次页面加载后检查当前任务阶段,执行对应动作 function checkBatchState() { var state = BatchStateStore.get(); if (!state.running) return; // 没跑任务就啥也不做 setStatus('#f44336', '批量收藏任务运行中!\n请勿切换标签页/关闭页面'); batchLog('🔄 恢复任务,当前阶段:' + state.step); // 阶段1:搜索页,收集商家队列 if (state.step === 'wait_search') { batchLog('⌛ 等待搜索结果加载...'); var searchTimer = setInterval(function () { var curState = BatchStateStore.get(); if (!curState.running) { clearInterval(searchTimer); return; } if (Object.keys(pageItems).length >= 8) { clearInterval(searchTimer); batchLog('✅ 搜索结果加载完成,共' + Object.keys(pageItems).length + '个商品'); var sellerSet = new Set(); var filteredDone = 0; var filteredBad = 0; for (var iid in pageItems) { var sid = pageItems[iid].sellerId; if (!sid) continue; var sidStr = String(sid).trim(); // 过滤明显的乱码ID:带=、--、空格的非法字符串 if (/[=\-\s]/.test(sidStr) || sidStr.length < 5) { filteredBad++; continue; } if (BatchDoneStore.check(sidStr)) { filteredDone++; continue; } sellerSet.add(sidStr); } curState.queue = Array.from(sellerSet).slice(0, BATCH_CONFIG.MAX_TASK_COUNT); batchLog('✅ 共' + Object.keys(pageItems).length + '个商品,过滤:' + filteredDone + '个已处理、' + filteredBad + '个非法ID'); batchLog('✅ 待处理商家:' + curState.queue.length + '个'); if (curState.queue.length === 0) { batchLog('ℹ️ 没有新商家需要处理,任务结束'); return endBatchTask(); } // 取第一个商家,跳他的主页 curState.current_sid = curState.queue.shift(); curState.step = 'visit_seller'; BatchStateStore.set(curState); var delay = batchRandomDelay(); batchLog('⌛ 随机等待' + Math.floor(delay/1000) + '秒,跳转到卖家主页:' + curState.current_sid); setTimeout(function () { location.href = 'https://www.goofish.com/personal?userId=' + curState.current_sid; }, delay); } }, 1000); // 20秒超时 setTimeout(function () { clearInterval(searchTimer); }, 20000); } // 阶段2:卖家主页,找店铺第一个商品 else if (state.step === 'visit_seller') { batchLog('⌛ 等待卖家主页商品加载...'); var sellerTimer = setInterval(function () { var curState = BatchStateStore.get(); if (!curState.running) { clearInterval(sellerTimer); return; } // 检测页面错误:不合法参数/页面不存在 直接跳过 var bodyText = document.body.innerText || ''; if (bodyText.indexOf('不合法的参数') >= 0 || bodyText.indexOf('页面不存在') >= 0 || bodyText.indexOf('页面出错') >= 0) { clearInterval(sellerTimer); batchLog('❌ 卖家ID无效,页面报参数错误,跳过'); curState.fail++; curState.consecutive_fail++; BatchStateStore.set(curState); nextSeller(); return; } var links = document.querySelectorAll('a[href*="/item?id="]'); if (links.length > 0) { clearInterval(sellerTimer); var firstItemUrl = links[0].href; batchLog('🔗 店铺第一个商品:' + firstItemUrl); curState.step = 'fav_item'; curState.item_url = firstItemUrl; BatchStateStore.set(curState); var delay = batchRandomDelay(); batchLog('⌛ 随机等待' + Math.floor(delay/1000) + '秒,跳转到商品页'); setTimeout(function () { location.href = firstItemUrl; }, delay); } }, 1000); setTimeout(function () { clearInterval(sellerTimer); var curState = BatchStateStore.get(); if (!curState.running) return; batchLog('❌ 卖家主页未找到在售商品,跳过'); curState.fail++; curState.consecutive_fail++; BatchStateStore.set(curState); nextSeller(); }, BATCH_CONFIG.WAIT_ELEMENT_TIMEOUT); } // 阶段3:商品页,点收藏按钮 else if (state.step === 'fav_item') { batchLog('⌛ 等待收藏按钮加载...'); var favTimer = setInterval(function () { var curState = BatchStateStore.get(); if (!curState.running) { clearInterval(favTimer); return; } // 检测页面错误 var bodyText = document.body.innerText || ''; if (bodyText.indexOf('不合法的参数') >= 0 || bodyText.indexOf('页面不存在') >= 0 || bodyText.indexOf('页面出错') >= 0) { clearInterval(favTimer); batchLog('❌ 商品页报错,跳过'); curState.fail++; curState.consecutive_fail++; BatchStateStore.set(curState); nextSeller(); return; } var btns = document.querySelectorAll('button, div[role="button"]'); var foundBtn = null; for (var i = 0; i < btns.length; i++) { var t = (btns[i].textContent || '').trim(); var aria = btns[i].getAttribute('aria-label') || ''; if ((t.indexOf('收藏') >= 0 || aria.indexOf('收藏') >= 0) && btns[i].offsetParent !== null) { foundBtn = btns[i]; break; } } if (foundBtn) { clearInterval(favTimer); batchLog('🖱️ 点击收藏按钮'); foundBtn.click(); setTimeout(function () { var st2 = BatchStateStore.get(); if (!st2.running) return; st2.success++; st2.consecutive_fail = 0; BatchDoneStore.markDone(st2.current_sid); batchLog('✅ 商家' + st2.current_sid + '收藏成功,写入黑名单'); BatchStateStore.set(st2); nextSeller(); }, 2000); } }, 1000); setTimeout(function () { clearInterval(favTimer); var curState = BatchStateStore.get(); if (!curState.running) return; batchLog('❌ 商品页未找到收藏按钮,跳过'); curState.fail++; curState.consecutive_fail++; BatchStateStore.set(curState); nextSeller(); }, BATCH_CONFIG.WAIT_ELEMENT_TIMEOUT); } } // 处理下一个商家 function nextSeller() { var state = BatchStateStore.get(); if (!state.running) return; // 检查连续失败 if (state.consecutive_fail >= BATCH_CONFIG.MAX_CONSECUTIVE_FAIL) { batchLog('⚠️ 连续失败' + BATCH_CONFIG.MAX_CONSECUTIVE_FAIL + '次,自动停止任务(可能弹滑块/页面改版)'); return endBatchTask(); } // 队列空了就结束 if (state.queue.length === 0) { return endBatchTask(); } // 取下一个 state.current_sid = state.queue.shift(); state.step = 'visit_seller'; BatchStateStore.set(state); var delay = batchRandomDelay(); batchLog('⌛ 随机等待' + Math.floor(delay/1000) + '秒,处理下一个商家:' + state.current_sid); setTimeout(function () { location.href = 'https://www.goofish.com/personal?userId=' + state.current_sid; }, delay); } // 任务结束 function endBatchTask() { var state = BatchStateStore.get(); batchLog('🏁 任务结束!成功:' + state.success + '个,失败:' + state.fail + '个'); state.running = false; BatchStateStore.set(state); setStatus('#4caf50', '闲鱼收藏提醒运行中 ✓\n点击查看调试信息'); if (batchStartBtn) batchStartBtn.disabled = false; } // ============================================================ // 油猴菜单 // ============================================================ function setupMenu() { try { GM_registerMenuCommand('🧪 测试:弹出两种提醒框', function () { showCard('fav', '
【测试】收藏提醒:该卖家有 3 件商品在收藏中
¥99 · 测试商品A
¥199 · 测试商品B
测试提醒,10秒后消失
'); showCard('revisit', '
【测试】再次访问:这是你第 5 次进入该卖家主页
首次:3天前 · 上次:2小时前
测试提醒
'); }); GM_registerMenuCommand('🧪 测试:商品页收藏提醒', function () { showCard('fav', '
【测试】收藏提醒:该卖家有 3 件商品在收藏中
当前商品:¥500 · 测试商品
¥99 · 测试商品A
测试提醒,10秒后消失
'); }); GM_registerMenuCommand('🔴 重新标记当前列表(常驻角标)', function () { if (!isSearchPage(location.href) && !isItemPage(location.href)) { alert('请在搜索结果页、首页或商品详情页使用此功能。'); return; } var marked = document.querySelectorAll('[data-xy-marked="1"]'); for (var i = 0; i < marked.length; i++) { marked[i].dataset.xyMarked = ''; if (marked[i].classList) marked[i].classList.remove('xy-hit-card'); marked[i].style.outline = ''; marked[i].style.outlineOffset = ''; marked[i].style.boxShadow = ''; var oldBadge = marked[i].querySelector('.xy-badge-box'); if (oldBadge) oldBadge.parentNode.removeChild(oldBadge); } highlightMatchedCards(); var cnt = document.querySelectorAll('[data-xy-marked="1"]').length; alert('已重新标记当前页面,共 ' + cnt + ' 个商品卡片命中(左上角常驻角标,悬浮时外框变红)。\n(F12控制台可看详细统计)'); }); GM_registerMenuCommand('🔄 刷新收藏数据(先打开收藏页)', function () { FavStore.clear(); alert('已清除收藏缓存!\n请前往「我的」→「收藏」浏览一次。'); }); GM_registerMenuCommand('📊 查看收藏索引', function () { var s = FavStore.get(), n = Object.keys(s.data).length; if (!n) { alert('收藏索引为空,请先访问收藏页面。'); return; } var m = '共 ' + n + ' 个收藏卖家(更新于 ' + new Date(s.updatedAt).toLocaleString('zh-CN') + '):\n\n'; var cnt = 0; for (var k in s.data) { if (cnt++ >= 50) break; m += '• ' + k + ': ' + s.data[k].count + '件\n'; } if (n > 50) m += '... 还有 ' + (n - 50) + ' 个'; alert(m); }); GM_registerMenuCommand('👀 查看访问历史', function () { var all = VisitStore.get(), n = Object.keys(all).length; if (!n) { alert('暂无访问历史。'); return; } var arr = []; for (var k in all) arr.push([k, all[k]]); arr.sort(function (a, b) { return b[1].lastVisit - a[1].lastVisit; }); var m = '共访问过 ' + n + ' 个卖家(无上限):\n\n'; for (var i = 0; i < Math.min(50, arr.length); i++) { m += '• ' + arr[i][0] + '\n 访问' + arr[i][1].visitCount + '次 · 上次' + timeAgo(arr[i][1].lastVisit) + ' · 首次' + timeAgo(arr[i][1].firstVisit) + '\n'; } if (n > 50) m += '\n... 还有 ' + (n - 50) + ' 个'; alert(m); }); // ===== v1.4.13:批量收藏任务功能已停用(代码保留,不影响其他功能) ===== // GM_registerMenuCommand('🤖 批量任务:显示/隐藏控制面板', function () { // if (!batchPanel) { createBatchPanel(); return; } // batchPanel.style.display = batchPanel.style.display === 'none' ? 'block' : 'none'; // }); // GM_registerMenuCommand('🤖 批量任务:查看已处理商家数量', function () { // var all = BatchDoneStore.get(); // alert('批量任务已成功处理商家总数:' + Object.keys(all).length + '个\n(这些商家下次跑任务会自动跳过)'); // }); GM_registerMenuCommand('🗑️ 清除收藏缓存', function () { if (confirm('确定清除收藏缓存?')) { FavStore.clear(); alert('已清除!'); } }); GM_registerMenuCommand('🗑️ 清除访问历史', function () { if (confirm('确定清除访问历史?')) { VisitStore.clear(); alert('已清除!'); } }); GM_registerMenuCommand('🧹 清除别名缓存(修复30个卖家映射到同一ID)', function () { if (confirm('清除 SidAliasStore 里的错误别名?\n清除后搜索页将只依赖 ItemUserStore 反查。')) { SidAliasStore.clear(); alert('别名缓存已清空!请刷新页面后重新访问卖家主页测试。'); } }); GM_registerMenuCommand('🔧 手动采集当前主页商品', function () { if (!isProfile(location.href)) { alert('请在卖家主页使用此功能。'); return; } var sid = getUserId(location.href); collectProfileItemIds(sid, 0); alert('已触发采集,请查看控制台日志(筛选"闲鱼收藏提醒")。'); }); // ---- v1.5.0:云端共享菜单 ---- GM_registerMenuCommand('☁️ ' + (isCloudEnabled() ? '关闭云端共享' : '开启云端共享'), function () { var on = !isCloudEnabled(); setCloudEnabled(on); if (on) { startSyncTimer(); alert('云端共享已开启!\n正在同步数据...'); } else { if (syncTimer) { clearInterval(syncTimer); syncTimer = null; } alert('云端共享已关闭。\n你的数据将不再上传,也不会下载共享数据。'); } }); GM_registerMenuCommand('☁️ 立即同步云端数据', function () { if (!isCloudEnabled()) { alert('云端共享当前是关闭状态,请先开启。'); return; } if (!CLOUD_CONFIG.SUPABASE_URL) { alert('尚未配置 Supabase URL。\n请编辑脚本顶部 CLOUD_CONFIG 填入你的 Supabase 地址和 Key。'); return; } syncNow(); alert('已触发同步!\n请查看控制台日志(筛选"闲鱼收藏提醒")确认结果。'); }); GM_registerMenuCommand('☁️ 查看云端配置状态', function () { var msg = '【云端共享状态 v1.5.0】\n\n'; msg += '开关: ' + (isCloudEnabled() ? '✅ 已开启' : '❌ 已关闭') + '\n'; msg += 'Supabase URL: ' + (CLOUD_CONFIG.SUPABASE_URL ? '已配置' : '❌ 未配置') + '\n'; msg += '同步间隔: ' + (CLOUD_CONFIG.SYNC_INTERVAL / 3600000).toFixed(1) + '小时\n'; try { var uf = GM_getValue(CLOUD_CONFIG.UPLOADED_FAV_KEY, '{}'); var uv = GM_getValue(CLOUD_CONFIG.UPLOADED_VISIT_KEY, '{}'); msg += '已上传收藏卖家: ' + Object.keys(JSON.parse(uf||'{}')).length + ' 个\n'; msg += '已上传浏览卖家: ' + Object.keys(JSON.parse(uv||'{}')).length + ' 个\n'; } catch(e) {} msg += '设备ID: ' + getDeviceId().substring(0, 20) + '...\n'; msg += '共享卖家缓存: ' + SharedStore.count() + ' 个\n'; msg += '上次同步: ' + (GM_getValue(CLOUD_CONFIG.SHARED_LAST_SYNC_KEY, '') || '从未') + '\n'; msg += '\n配置方法: 编辑脚本顶部 CLOUD_CONFIG\n填入你的 Supabase URL 和 anon key'; alert(msg); }); GM_registerMenuCommand('☁️ 设置 Supabase 地址', function () { var url = prompt('请输入 Supabase URL(例: https://xxxx.supabase.co):', CLOUD_CONFIG.SUPABASE_URL || ''); if (url === null) return; CLOUD_CONFIG.SUPABASE_URL = url.trim(); try { GM_setValue('xianyu_supabase_url', CLOUD_CONFIG.SUPABASE_URL); } catch (e) { } alert('URL 已设置(需刷新页面生效)'); }); GM_registerMenuCommand('☁️ 设置 Supabase Anon Key', function () { var key = prompt('请输入 Supabase anon public key:', CLOUD_CONFIG.SUPABASE_ANON_KEY || ''); if (key === null) return; CLOUD_CONFIG.SUPABASE_ANON_KEY = key.trim(); try { GM_setValue('xianyu_supabase_key', CLOUD_CONFIG.SUPABASE_ANON_KEY); } catch (e) { } alert('Key 已设置(需刷新页面生效)'); }); log('菜单已注册'); } catch (e) { warn('菜单注册失败', e); } } // ============================================================ // 初始化(每个步骤独立 try-catch,互不影响) // ============================================================ log('脚本启动 v1.5.2 | URL:', location.href, '| readyState:', document.readyState); // v1.5.0:从本地存储读取 Supabase 配置 try { var savedUrl = GM_getValue('xianyu_supabase_url', ''); var savedKey = GM_getValue('xianyu_supabase_key', ''); if (savedUrl) CLOUD_CONFIG.SUPABASE_URL = savedUrl; if (savedKey) CLOUD_CONFIG.SUPABASE_ANON_KEY = savedKey; } catch (e) { } var steps = [ ['XHR hook', hookXHR], ['fetch hook', hookFetch], ['SPA监听', setupSpa], ['油猴菜单', setupMenu], ['页面检测', checkPage], ['云端同步', startSyncTimer], // v1.4.13:批量面板已停用 // ['批量面板', createBatchPanel], ]; var okCount = 0; for (var i = 0; i < steps.length; i++) { try { steps[i][1](); okCount++; } catch (e) { warn('步骤[' + steps[i][0] + ']失败:', e); initError = e; } } if (okCount === steps.length) { setStatus('#4caf50', '闲鱼收藏提醒运行中 ✓\n点击查看调试信息'); log('全部初始化完成 ✓ (' + okCount + '/' + steps.length + ')'); } else { setStatus('#ff9800', '部分功能初始化失败 (' + okCount + '/' + steps.length + ')\n点击查看调试信息'); warn('部分初始化失败:', okCount + '/' + steps.length); } })();