// ==UserScript== // @name Douyin Batch City Search + AutoScroll + Capture // @namespace http://tampermonkey.net/ // @version 1.1 // @description 从 Python 服务获取地区列表,按 city + "律师" 搜索并自动下滑,拦截 /aweme/v1/web/discover/search/ 返回并转发到入库接口。 // @author You // @match https://www.douyin.com/* // @grant GM_xmlhttpRequest // @connect * // @run-at document-idle // ==/UserScript== (function () { 'use strict'; /********************* 配置区(按需修改) *********************/ const API_BASE = 'http://127.0.0.1:9002'; // 改成你部署 Python 服务的地址,例如 http://nas.nepiedg.site:9002 const AREA_API = `${API_BASE}/api/layer/get_area?server=1`; // 获取城市列表的接口 const SEND_TARGETS = [ `${API_BASE}/api/layer/index?server=1&save_only=0` ]; // 搜索框与按钮选择器(根据页面更新) const SEARCH_INPUT_SELECTORS = [ 'input[data-e2e="search-input"]', 'input[data-e2e="searchbar-input"]', 'form[data-e2e="searchbar"] input', 'input[placeholder*="搜索"]' ]; const SEARCH_BTN_SELECTORS = [ '[data-e2e="search-button"]', 'button[data-e2e="search-button"]', 'span[data-e2e="search-button"]', 'button[data-e2e="searchbar-button"]', 'span.btn-title' ]; // 每个城市搜索时的自动下滑配置 const SCROLL_INTERVAL_MS = 2000; const MAX_STABLE_COUNT = 6; const MAX_SCROLLS_PER_CITY = 120; const SCROLL_BY = 2200; const WAIT_AFTER_SEARCH_MS = 1000; const DELAY_BETWEEN_CITIES_MS = 1500; // 断点续跑配置 const PROGRESS_STORAGE_KEY = 'dm_batch_progress_v1'; const DEVICE_ID_STORAGE_KEY = 'dm_batch_device_id_v1'; const PROGRESS_SYNC_ENABLED = true; const PROGRESS_KEY = 'douyin_batch_default'; const PROGRESS_API = `${API_BASE}/api/layer/progress?server=1`; // 可选:如果希望只发送包含手机号的条目,可在此启用并调整正则 const ONLY_SEND_IF_HAS_PHONE = false; const PHONE_REGEX = /(?:\+?86)?1[3-9]\d{9}/g; /********************* 运行时状态 *********************/ let areaList = []; let stopFlag = false; // 由 UI 控制,true 表示停止整个任务 let skipCurrentCityFlag = false; // 由 UI 控制,true 表示跳过当前城市 let currentCityIndex = -1; let currentAreaSignature = ''; let isLoopRunning = false; let inputEl = null; let btnEl = null; const DEVICE_ID = getOrCreateDeviceId(); // 节流/去重发送 let lastSentHash = null; let lastSentAt = 0; const SEND_MIN_INTERVAL_MS = 800; let progressSyncInFlight = false; let progressSyncPendingPayload = null; /********************* 工具函数 *********************/ function log(...args) { console.log('[DouyinBatch] ', ...args); } function err(...args) { console.error('[DouyinBatch] ', ...args); } function hashString(str) { let h = 2166136261 >>> 0; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; } return h.toString(16); } function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } function getOrCreateDeviceId() { try { const old = localStorage.getItem(DEVICE_ID_STORAGE_KEY); if (old) return old; const generated = (window.crypto && typeof window.crypto.randomUUID === 'function') ? window.crypto.randomUUID() : `dm-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; localStorage.setItem(DEVICE_ID_STORAGE_KEY, generated); return generated; } catch (_) { return `dm-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`; } } function getAreaRowName(row) { if (!row || typeof row !== 'object') return ''; return String(row.city || row.province || row.name || '').trim(); } function buildAreaSignature(list) { try { if (!Array.isArray(list) || list.length === 0) return 'empty'; const names = list.map(getAreaRowName).filter(Boolean); return hashString(`${list.length}|${names.join('|')}`); } catch (e) { return 'unknown'; } } function readProgress() { try { const raw = localStorage.getItem(PROGRESS_STORAGE_KEY); if (!raw) return null; const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== 'object') return null; return parsed; } catch (_) { return null; } } function buildProgressPayload(nextCityIndex, reason = '') { const safeIndex = Number.isFinite(nextCityIndex) ? Math.max(0, Math.floor(nextCityIndex)) : 0; const currentArea = areaList[safeIndex] || areaList[Math.max(0, currentCityIndex)] || {}; return { progress_key: PROGRESS_KEY, device_id: DEVICE_ID, next_city_index: safeIndex, area_signature: currentAreaSignature || '', area_total: Array.isArray(areaList) ? areaList.length : 0, current_city: getAreaRowName(currentArea), reason, status: stopFlag ? 'paused' : 'running', extra: { path: location.pathname || '', href: location.href || '', }, }; } function persistProgress(nextCityIndex, reason = '') { try { const payload = buildProgressPayload(nextCityIndex, reason); localStorage.setItem(PROGRESS_STORAGE_KEY, JSON.stringify({ nextCityIndex: payload.next_city_index, areaSignature: payload.area_signature, reason: payload.reason, updatedAt: Date.now(), progressKey: payload.progress_key, deviceId: payload.device_id, })); enqueueRemoteProgressSync(payload); } catch (e) { err('保存进度失败', e); } } function restoreProgress(areaSignature, listLength) { const progress = readProgress(); if (!progress) return 0; if (!progress.areaSignature || progress.areaSignature !== areaSignature) return 0; const idx = Number.isFinite(progress.nextCityIndex) ? Math.floor(progress.nextCityIndex) : 0; if (idx < 0 || idx >= listLength) return 0; return idx; } function clearProgress() { try { localStorage.removeItem(PROGRESS_STORAGE_KEY); } catch (_) {} enqueueRemoteProgressSync({ action: 'clear', progress_key: PROGRESS_KEY, device_id: DEVICE_ID, }); } function gmGetJson(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, onload(res) { try { const json = JSON.parse(res.responseText); resolve(json); } catch (e) { reject(e); } }, onerror(err) { reject(err); } }); }); } function gmPostJson(url, data) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'POST', url, headers: { 'Content-Type': 'application/json' }, data: JSON.stringify(data || {}), onload(res) { try { const json = JSON.parse(res.responseText || '{}'); resolve(json); } catch (e) { reject(e); } }, onerror(err) { reject(err); } }); }); } function enqueueRemoteProgressSync(payload) { if (!PROGRESS_SYNC_ENABLED) return; if (!payload || typeof payload !== 'object') return; progressSyncPendingPayload = payload; if (progressSyncInFlight) return; flushRemoteProgressSync(); } async function flushRemoteProgressSync() { if (!PROGRESS_SYNC_ENABLED) return; if (progressSyncInFlight) return; progressSyncInFlight = true; try { while (progressSyncPendingPayload) { const payload = progressSyncPendingPayload; progressSyncPendingPayload = null; try { await gmPostJson(PROGRESS_API, payload); } catch (e) { err('同步远端进度失败', e); break; } } } finally { progressSyncInFlight = false; } } async function restoreRemoteProgress(areaSignature, listLength) { if (!PROGRESS_SYNC_ENABLED) return 0; try { const url = `${PROGRESS_API}&progress_key=${encodeURIComponent(PROGRESS_KEY)}`; const response = await gmGetJson(url); const data = response && response.data ? response.data : null; if (!data || typeof data !== 'object') return 0; const remoteSignature = String(data.area_signature || ''); if (!remoteSignature || remoteSignature !== areaSignature) return 0; const idxRaw = data.next_city_index; const idx = Number.isFinite(idxRaw) ? Math.floor(idxRaw) : Math.floor(Number(idxRaw || 0)); if (!Number.isFinite(idx) || idx < 0 || idx >= listLength) return 0; return idx; } catch (e) { err('读取远端进度失败', e); return 0; } } function setNativeValue(el, value) { if (!el) return; const prototype = el.constructor && el.constructor.prototype ? el.constructor.prototype : window.HTMLInputElement && window.HTMLInputElement.prototype; const descriptor = prototype ? Object.getOwnPropertyDescriptor(prototype, 'value') : null; if (descriptor && descriptor.set) { descriptor.set.call(el, value); } else { el.value = value; } } async function simulateSearchInput(keyword) { if (!inputEl) return; try { inputEl.focus(); inputEl.dispatchEvent(new Event('focus', { bubbles: false })); // 清空旧值并触发事件 if (inputEl.value) { setNativeValue(inputEl, ''); if (typeof InputEvent === 'function') { inputEl.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: '' })); } else { inputEl.dispatchEvent(new Event('input', { bubbles: true })); } } setNativeValue(inputEl, keyword); if (typeof InputEvent === 'function') { inputEl.dispatchEvent(new InputEvent('beforeinput', { bubbles: true, inputType: 'insertText', data: keyword })); inputEl.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertText', data: keyword })); } else { inputEl.dispatchEvent(new Event('input', { bubbles: true })); } inputEl.dispatchEvent(new Event('change', { bubbles: true })); inputEl.dispatchEvent(new Event('blur', { bubbles: false })); } catch (e) { err('simulateSearchInput error', e); } await new Promise(r => setTimeout(r, 80)); } function simulateSearchTrigger() { let triggered = false; if (btnEl && btnEl.isConnected) { try { btnEl.focus(); btnEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window })); btnEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window })); btnEl.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); triggered = true; } catch (e) { err('simulateSearchTrigger click error', e); } } if (!triggered && inputEl) { try { const opts = { bubbles: true, cancelable: true, key: 'Enter', code: 'Enter', keyCode: 13, which: 13 }; inputEl.dispatchEvent(new KeyboardEvent('keydown', opts)); inputEl.dispatchEvent(new KeyboardEvent('keypress', opts)); inputEl.dispatchEvent(new KeyboardEvent('keyup', opts)); triggered = true; } catch (e) { err('Enter 触发搜索失败', e); } } return triggered; } function sendToTargets(data) { try { const body = typeof data === 'string' ? data : JSON.stringify(data); if (ONLY_SEND_IF_HAS_PHONE) { if (!PHONE_REGEX.test(body)) { // 未匹配手机号则跳过发送 return; } } const hash = hashString(body); const now = Date.now(); if (hash === lastSentHash && now - lastSentAt < SEND_MIN_INTERVAL_MS) { return; } lastSentHash = hash; lastSentAt = now; for (const target of SEND_TARGETS) { GM_xmlhttpRequest({ method: 'POST', url: target, headers: { 'Content-Type': 'application/json' }, data: body, onload(res) { log(`sent -> ${target}, status: ${res.status}`); }, onerror(e) { err(`send error to ${target}`, e); } }); } } catch (e) { err('sendToTargets error', e); } } /********************* 拦截 fetch 与 XHR(捕获目标接口返回) *********************/ const TARGET_PATH = '/aweme/v1/web/discover/search/'; (function interceptFetch() { if (!window.fetch) return; const orig = window.fetch.bind(window); window.fetch = function (...args) { try { const resource = args[0]; const url = (typeof resource === 'string') ? resource : (resource && resource.url) ? resource.url : ''; if (url && url.includes(TARGET_PATH)) { return orig(...args).then((response) => { try { const cloned = response.clone(); cloned.json().then((json) => { if (json && typeof json === 'object') { sendToTargets({ source: 'fetch', url, data: json, ts: Date.now(), cityIndex: currentCityIndex }); } }).catch(()=>{}); } catch (e) { /* ignore */ } return response; }); } } catch (e) { err('fetch wrapper error', e); } return orig(...args); }; })(); (function interceptXHR() { const XHR = window.XMLHttpRequest; if (!XHR) return; const origOpen = XHR.prototype.open; const origSend = XHR.prototype.send; XHR.prototype.open = function (method, url, ...rest) { try { this.__dm_url = (typeof url === 'string') ? url : ''; } catch(e){} return origOpen.apply(this, [method, url, ...rest]); }; XHR.prototype.send = function (body) { try { const targetUrl = this.__dm_url || ''; if (targetUrl && targetUrl.includes(TARGET_PATH)) { this.addEventListener('readystatechange', function () { if (this.readyState === 4) { try { const text = this.responseText; if (!text) return; try { const json = JSON.parse(text); sendToTargets({ source: 'xhr', url: targetUrl, data: json, ts: Date.now(), cityIndex: currentCityIndex }); } catch (err) { // 非 json 忽略 } } catch (e) { /* ignore */ } } }); } } catch (e) { err('XHR wrapper error', e); } return origSend.apply(this, [body]); }; })(); /********************* 自动下滑函数(单次搜索) *********************/ async function autoScrollUntilStable(statusNode, maxScrolls = MAX_SCROLLS_PER_CITY) { let lastHeight = -1; let stableCount = 0; let scrolls = 0; while (!stopFlag) { if (skipCurrentCityFlag) { statusNode.textContent = '收到跳过指令,结束当前地区滚动。'; break; } scrolls++; if (scrolls > maxScrolls) { statusNode.textContent = `达到单次搜索最大滚动 ${maxScrolls},停止本次自动下滑。`; break; } // 执行滚动 try { window.scrollBy({ top: SCROLL_BY, left: 0, behavior: 'smooth' }); } catch (e) { window.scrollTo(0, (document.body.scrollHeight || document.documentElement.scrollHeight)); } await sleep(SCROLL_INTERVAL_MS); if (skipCurrentCityFlag) { statusNode.textContent = '收到跳过指令,结束当前地区滚动。'; break; } const curHeight = document.body.scrollHeight || document.documentElement.scrollHeight || 0; if (curHeight === lastHeight) { stableCount++; } else { stableCount = 0; lastHeight = curHeight; } statusNode.textContent = `滚动次数: ${scrolls}, 稳定计数: ${stableCount}/${MAX_STABLE_COUNT}`; if (stableCount >= MAX_STABLE_COUNT) { statusNode.textContent = `页面高度稳定 (${stableCount}), 本次搜索加载结束。`; break; } } } /********************* 页面元素辅助:等待元素出现 *********************/ function waitForSelector(selector, timeout = 10000) { const selectors = Array.isArray(selector) ? selector.filter(Boolean) : [selector]; return new Promise((resolve, reject) => { let timer; const root = document.documentElement || document.body; const cleanup = (observer) => { try { observer && observer.disconnect(); } catch (_) {} if (timer) clearTimeout(timer); }; const pick = () => { for (const sel of selectors) { if (!sel) continue; try { const found = document.querySelector(sel); if (found) { return found; } } catch (e) { err('query selector error', sel, e); } } return null; }; const immediate = pick(); if (immediate) { return resolve(immediate); } const observer = new MutationObserver(() => { const node = pick(); if (node) { cleanup(observer); resolve(node); } }); if (root) { observer.observe(root, { childList: true, subtree: true }); } timer = setTimeout(() => { cleanup(observer); reject(new Error('timeout waiting for ' + selectors.join(', '))); }, timeout); }); } async function ensureSearchControls(statusNode) { const isConnected = (node) => { if (!node) return false; try { if (node.isConnected !== undefined) return node.isConnected; return document.contains(node); } catch (_) { return false; } }; if (!isConnected(inputEl)) inputEl = null; if (!isConnected(btnEl)) btnEl = null; if (!inputEl) { statusNode && (statusNode.textContent = '等待搜索输入框可用...'); inputEl = await waitForSelector(SEARCH_INPUT_SELECTORS, 10000); } if (!btnEl) { try { statusNode && (statusNode.textContent = '等待搜索按钮可用...'); btnEl = await waitForSelector(SEARCH_BTN_SELECTORS, 8000); if (btnEl && btnEl.tagName !== 'BUTTON') { const maybeButton = btnEl.closest('button'); if (maybeButton) btnEl = maybeButton; } } catch (e) { btnEl = null; err('未找到搜索按钮,将使用 Enter 键进行触发。'); } } if (!inputEl) { throw new Error('未定位到搜索输入框'); } return { inputEl, btnEl }; } /********************* UI 控制(右下角) *********************/ function createUI() { const css = ` #dm-batch-btn { position: fixed; right: 12px; bottom: 12px; z-index:999999; background: rgba(0,0,0,0.65); color:#fff; padding:8px 10px; border-radius:8px; font-size:13px; cursor:pointer; user-select:none;} #dm-batch-skip { position: fixed; right:12px; bottom:50px; z-index:999999; background: rgba(30,30,30,0.72); color:#fff; padding:7px 10px; border-radius:8px; font-size:12px; cursor:pointer; user-select:none;} #dm-batch-status { position: fixed; right:12px; bottom:88px; z-index:999999; background: rgba(0,0,0,0.45); color:#fff; padding:6px 8px; border-radius:6px; font-size:12px; max-width:320px; word-break:break-word;} `; const s = document.createElement('style'); s.textContent = css; document.head && document.head.appendChild(s); const btn = document.createElement('div'); btn.id = 'dm-batch-btn'; btn.textContent = 'BatchSearch:停止'; btn.dataset.running = '1'; document.body.appendChild(btn); const skipBtn = document.createElement('div'); skipBtn.id = 'dm-batch-skip'; skipBtn.textContent = 'BatchSearch:跳过当前'; document.body.appendChild(skipBtn); const status = document.createElement('div'); status.id = 'dm-batch-status'; status.textContent = '准备中...'; document.body.appendChild(status); btn.addEventListener('click', () => { const running = btn.dataset.running === '1'; btn.dataset.running = running ? '0' : '1'; btn.textContent = running ? 'BatchSearch:已停止' : 'BatchSearch:停止'; status.textContent = running ? '已手动停止(已保存断点)' : '已启动'; stopFlag = running; // if was running and clicked -> set stopFlag true; if restarting, set false if (running) { skipCurrentCityFlag = false; persistProgress(Math.max(currentCityIndex, 0), 'manual_pause'); } if (!stopFlag) { // restart loop if needed runBatchSearchLoop(status).catch(e => err(e)); } }); skipBtn.addEventListener('click', () => { if (currentCityIndex < 0) { status.textContent = '当前还未开始处理城市,稍后再跳过。'; return; } skipCurrentCityFlag = true; const areaName = getAreaRowName(areaList[currentCityIndex] || {}); status.textContent = `收到跳过指令:${areaName || `索引${currentCityIndex}`}`; }); skipBtn.addEventListener('contextmenu', (event) => { event.preventDefault(); clearProgress(); currentCityIndex = 0; status.textContent = '已清除断点。下次将从第 1 个地区开始。'; }); return { btn, skipBtn, status }; } /********************* 主流程:获取城市并循环搜索 *********************/ async function runBatchSearchLoop(statusNode) { if (isLoopRunning) { statusNode.textContent = '批量任务已在运行中,请勿重复启动。'; return; } isLoopRunning = true; try { stopFlag = (document.getElementById('dm-batch-btn') && document.getElementById('dm-batch-btn').dataset.running === '0'); skipCurrentCityFlag = false; if (stopFlag) { statusNode.textContent = '当前是暂停状态,点击“BatchSearch:停止”可继续。'; return; } // 获取 area list(仅在内存为空时获取) if (!areaList || !Array.isArray(areaList) || areaList.length === 0) { statusNode.textContent = '正在获取城市列表...'; try { const data = await gmGetJson(AREA_API); const normalizedAreaList = Array.isArray(data) ? data : (data && Array.isArray(data.data) ? data.data : []); if (normalizedAreaList.length > 0) { areaList = normalizedAreaList; log('获取城市列表数量:', areaList.length); statusNode.textContent = `获取到 ${areaList.length} 个城市,准备开始循环。`; } else { err('area API returned not array', data); statusNode.textContent = '获取城市列表失败(返回格式异常)'; return; } } catch (e) { err('获取城市列表失败', e); statusNode.textContent = '获取城市列表失败: ' + e.message; return; } } currentAreaSignature = buildAreaSignature(areaList); const restoredIndexLocal = restoreProgress(currentAreaSignature, areaList.length); const restoredIndexRemote = await restoreRemoteProgress(currentAreaSignature, areaList.length); const restoredIndex = Math.max(restoredIndexLocal, restoredIndexRemote); const startIndex = (currentCityIndex >= 0 && currentCityIndex < areaList.length) ? currentCityIndex : restoredIndex; currentCityIndex = startIndex; if (startIndex > 0) { statusNode.textContent = `检测到断点(本地:${restoredIndexLocal + 1} 远端:${restoredIndexRemote + 1}),将从第 ${startIndex + 1}/${areaList.length} 个地区继续。`; await sleep(500); } // 等待搜索输入与按钮可用 try { await ensureSearchControls(statusNode); } catch (e) { err('未找到搜索输入或按钮', e); statusNode.textContent = '未找到搜索输入或按钮,脚本仍会监听接口,但无法自动搜索。'; return; } let completedAll = true; // 主循环:对每个 city 执行搜索 -> 下滑 -> 发送结果 -> 下一 city for (let i = startIndex; i < areaList.length; i++) { if (stopFlag) { completedAll = false; persistProgress(i, 'manual_stop'); statusNode.textContent = '已停止(断点已保存)。'; break; } currentCityIndex = i; skipCurrentCityFlag = false; persistProgress(i, 'start_city'); const city = (areaList[i].city || areaList[i].province || '').trim(); if (!city) { persistProgress(i + 1, 'empty_city'); continue; } const keyword = `${city}律师`; statusNode.textContent = `正在搜索:${keyword} (${i+1}/${areaList.length})`; log(`开始城市[${i+1}/${areaList.length}] 搜索:`, keyword); // 将搜索词放入输入框 (触发 input 事件) try { await ensureSearchControls(statusNode); } catch (e) { err('刷新搜索控件失败', e); statusNode.textContent = '刷新搜索控件失败,终止批量搜索。'; completedAll = false; persistProgress(i, 'search_control_error'); break; } await simulateSearchInput(keyword); const triggered = simulateSearchTrigger(); if (!triggered) { statusNode.textContent = '搜索触发失败,尝试刷新控件...'; btnEl = null; await ensureSearchControls(statusNode); if (!simulateSearchTrigger()) { statusNode.textContent = '搜索触发失败,终止批量搜索。'; completedAll = false; persistProgress(i, 'search_trigger_error'); break; } } // 等待搜索结果开始加载 await new Promise(r => setTimeout(r, WAIT_AFTER_SEARCH_MS)); // 自动下滑直到稳定或达到上限 await autoScrollUntilStable(statusNode, MAX_SCROLLS_PER_CITY); if (skipCurrentCityFlag) { skipCurrentCityFlag = false; persistProgress(i + 1, 'skip_city'); statusNode.textContent = `已跳过 ${keyword},继续下一个地区...`; await sleep(Math.min(DELAY_BETWEEN_CITIES_MS, 800)); continue; } if (stopFlag) { completedAll = false; persistProgress(i, 'manual_stop_after_scroll'); statusNode.textContent = '已停止(断点已保存)。'; break; } persistProgress(i + 1, 'city_done'); // 等待短暂间隔再进行下一个城市 statusNode.textContent = `完成 ${keyword} 的加载,等待 ${DELAY_BETWEEN_CITIES_MS} ms 后继续...`; await sleep(DELAY_BETWEEN_CITIES_MS); } if (completedAll && !stopFlag) { clearProgress(); currentCityIndex = -1; statusNode.textContent = '批量搜索完成,已清除断点进度。'; log('批量搜索循环结束: completed'); } else { log('批量搜索循环结束: paused/broken'); } } catch (e) { err('runBatchSearchLoop error', e); persistProgress(Math.max(currentCityIndex, 0), 'loop_exception'); } finally { isLoopRunning = false; } } /********************* 启动脚本 *********************/ (function init() { window.addEventListener('beforeunload', () => { if (currentCityIndex >= 0) { persistProgress(Math.max(currentCityIndex, 0), 'page_unload'); } }); const ui = createUI(); ui.status.textContent = '就绪 - 可暂停/跳过,自动保存断点(右键跳过按钮可清除断点)'; console.log(location.pathname) // 如果当前为目标页面(/jingxuan/search/),则自动启动;否则仍可在任何页面打开并手动启动。 const isAutoPage = location.pathname && location.pathname.indexOf('/search/') !== -1; if (isAutoPage) { ui.status.textContent = '检测到 /jingxuan/search/ 页面,准备开始批量搜索...'; // 给页面一点时间加载必要脚本与 dom setTimeout(() => { runBatchSearchLoop(ui.status).catch(e => err(e)); }, 800); } else { // 非目标页面,仍可手动点击按钮(按钮初始化为运行状态,点击色变为已停止) ui.status.textContent = '非 /jingxuan/search/ 页面。导航至该页面或手动控制开始。'; } })(); })();