833 lines
30 KiB
JavaScript
833 lines
30 KiB
JavaScript
// ==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/ 页面。导航至该页面或手动控制开始。';
|
||
}
|
||
})();
|
||
|
||
})();
|
||
|