// ==UserScript== // @name B2Proxy IP助手 // @namespace local.b2proxy.all-in-one // @version 2.1.5 // @description 城市网段查询与复制;IPPure及IP2Location的AS Usage Type、Fraud Score、Anonymous Proxy独立批量检测;不下单 // @match https://dashboard.b2proxy.com/* // @match https://ippure.com/* // @match https://www.ip2location.com/* // @match https://ip2location.com/* // @grant GM_setClipboard // @grant GM_openInTab // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @run-at document-idle // ==/UserScript== (async function () { 'use strict'; const PAGE = '/zh-CN/proxy/isp'; const PANEL_ID = 'b2-city-segments-helper'; const SEARCH_KEY = 'b2-all-search-v2'; const JOB_PREFIX = 'b2-all-test-v2-'; const AS_USAGE_JOB_PREFIX = 'b2-ip2location-as-usage-v1-'; const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); const text = el => (el?.innerText || '').trim(); const norm = value => String(value || '') .replace(/\s+/g, '') .replace(/,/g, ',') .toLowerCase(); const visible = el => { if (!el?.getClientRects().length) return false; const style = getComputedStyle(el); return style.display !== 'none' && style.visibility !== 'hidden'; }; /* * IPPure端:仅处理本脚本创建的查询任务。 */ if (location.hostname === 'ippure.com') { const token = new URLSearchParams( location.hash.slice(1) ).get('b2test'); if (!token) return; const key = JOB_PREFIX + token; const ip = new URL(location.href).searchParams.get('ip'); const job = await GM_getValue(key, null); if ( !job || job.ip !== ip || Date.now() - job.created > 120000 ) return; function findLabel(name) { return [...document.querySelectorAll('.info-key')] .find(el => visible(el) && text(el).replace(/\s+/g, '') === name.replace(/\s+/g, '')); } // 正常使用 IPPure 的 info-key / info-value 布局。 // 位置里的 IP2Location、DB-IP、MaxMind 可能采用嵌套行,因此增加邻接节点回退。 function readValue(name) { const label = findLabel(name) || [...document.querySelectorAll('td,th,dt,span,div')] .find(el => visible(el) && el.children.length === 0 && text(el).replace(/\s+/g,'') === name.replace(/\s+/g,'')); if (!label) return ''; const parent = label.parentElement; const info = parent?.querySelector('.info-value'); if (info && info !== label && !info.contains(label)) return text(info); const row = label.closest('tr'); if (row) { const cells = [...row.children].filter(el => /^(TD|TH)$/.test(el.tagName)); if (cells.length > 1) return text(cells[cells.length - 1]); } const next = label.nextElementSibling || (parent?.children.length === 2 ? [...parent.children].find(child => child !== label) : null); return next ? text(next) : ''; } function readLocation() { // 图中的“位置”是分组名,并不是“国家/地区”字段。 // 优先 IP2Location 的地理位置,其次 DB-IP、MaxMind;保留国家/州/城市原文。 for (const name of ['IP2Location', 'DB-IP', 'MaxMind']) { const locationText = readValue(name).replace(/^[\s\u{1F1E6}-\u{1F1FF}]+/gu, '').trim(); if (locationText && locationText !== '—' && locationText !== '-') return locationText; } return readValue('国家/地区') || readValue('国家') || readValue('IP归属地') || '—'; } function readResult() { // 核验查询结果中的IP,不能误读顶部的 My IP。 const resultIP = text(document.querySelector('.info-value .highlight-text')); if (resultIP !== ip) return null; const source = readValue('IP来源'); const attribute = readValue('IP属性'); const country = readLocation(); const scoreBox = findLabel('IPPure系数')?.parentElement?.parentElement; const match = text(scoreBox).match( /(\d+(?:\.\d+)?)\s*%\s*(极度纯净|纯净|中性|轻度风险|中度风险|极度风险)/ ); if (!source || !attribute || !match) return null; return {ip:resultIP,source,attribute,country,score:Number(match[1]),rating:match[2]}; } let previous = ''; const deadline = Date.now() + 60000; while (Date.now() < deadline) { if (!await GM_getValue(key, null)) return; const result = readResult(); const signature = result ? JSON.stringify(result) : ''; // 连续两次读取一致,避免页面还在刷新结果。 if (signature && signature === previous) { await GM_setValue(key + '-result', { ok: true, ...result }); return; } previous = signature; await sleep(1000); } if (await GM_getValue(key, null)) { await GM_setValue(key + '-result', { ok: false, error: '页面未返回完整结果,可能需要验证或页面结构已变化。' }); } return; } /* * IP2Location 端:独立从同一个页面提取三个精确字段。 * 不把 Usage Type 误读成 AS Usage Type;不把 Proxy Usage Type 误读成 Anonymous Proxy。 * 即使某字段不存在,也保留其它字段;IP2Location 与 IPPure 的结果互不依赖。 */ if (location.hostname === 'www.ip2location.com' || location.hostname === 'ip2location.com') { const token = new URLSearchParams(location.hash.slice(1)).get('b2asusage'); if (!token) return; const ip = location.pathname.match(/^\/((?:\d{1,3}\.){3}\d{1,3})\/?$/)?.[1]; const key = AS_USAGE_JOB_PREFIX + token; const job = await GM_getValue(key,null); if (!ip || !job || job.ip !== ip || Date.now() - job.created > 120000) return; const normalize = value => String(value ?? '').replace(/\s+/g,' ').trim(); const validField = value => value && value !== '-' && value !== '—' && value !== 'N/A'; function readField(name) { // 页面目前使用表格字段;第一格必须与字段名称完全一致。 for (const row of document.querySelectorAll('tr')) { if (!visible(row)) continue; const cells = [...row.children].filter(el => /^(TD|TH)$/.test(el.tagName)); if (cells.length < 2 || normalize(text(cells[0])).replace(/:$/,'') !== name) continue; const value = normalize(text(cells[1])); return validField(value) ? value : ''; } // 兼容使用相邻元素呈现键和值的布局。 for (const node of document.querySelectorAll('dt,th,td,label,span,div')) { if (!visible(node) || node.children.length || normalize(text(node)).replace(/:$/,'') !== name) continue; const parent = node.parentElement; const sibling = node.nextElementSibling || (parent?.children.length === 2 ? [...parent.children].find(child => child !== node) : null); const value = normalize(text(sibling)); if (validField(value)) return value; } return ''; } let last = ''; const deadline = Date.now() + 60000; while (Date.now() < deadline) { if (!await GM_getValue(key,null)) return; const result = { asUsageType: readField('AS Usage Type'), fraudScore: readField('Fraud Score'), anonymousProxy: readField('Anonymous Proxy') }; const signature = Object.values(result).some(Boolean) ? JSON.stringify(result) : ''; // 连续两次读取一致才回传,避免数据仍在加载。各字段允许单独缺失。 if (signature && signature === last) { await GM_setValue(key + '-result',{ok:true,ip,...result}); return; } last = signature; await sleep(1000); } if (await GM_getValue(key,null)) { await GM_setValue(key + '-result',{ok:false,ip, error:'IP2Location 页面未读取到 AS Usage Type、Fraud Score 或 Anonymous Proxy(可能需要验证或页面结构已变化)'}); } return; } /* * B2Proxy端:面板、城市查询、复制和批量测试。 */ if (location.hostname !== 'dashboard.b2proxy.com') return; /* * 桌面左侧导航栏:收起 / 展开。 * 小窗口保留网站原来的移动端菜单。 */ (function setupSidebarToggle() { const BUTTON_ID = 'b2-desktop-sidebar-toggle'; const MARKER = 'b2-desktop-sidebar-target'; const COLLAPSED = 'b2-desktop-sidebar-collapsed'; const STORAGE_KEY = 'b2-desktop-sidebar-collapsed-v1'; if (document.getElementById(BUTTON_ID)) return; // 已从当前网站CSS确认:桌面布局从1025px开始。 const desktop = matchMedia('(min-width: 1025px)'); let collapsed = false; try { collapsed = sessionStorage.getItem(STORAGE_KEY) === '1'; } catch {} const style = document.createElement('style'); style.textContent = ` @media (min-width: 1025px) { .${MARKER}.${COLLAPSED} { display: none !important; } } #${BUTTON_ID} { position: fixed; left: 20px; bottom: 20px; z-index: 999998; display: inline-flex; align-items: center; justify-content: center; min-height: 36px; padding: 8px 13px; border: 1px solid #d8e3f5; border-radius: 8px; background: #ffffff; color: #2563eb; box-shadow: 0 3px 14px #0002; font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif; cursor: pointer; } #${BUTTON_ID}:hover { background: #eef5ff; } #${BUTTON_ID}[hidden] { display: none !important; } @media (max-width: 1024px) { #${BUTTON_ID} { display: none !important; } } `; document.head.appendChild(style); const button = document.createElement('button'); button.id = BUTTON_ID; button.type = 'button'; button.hidden = true; document.body.appendChild(button); function findDesktopSidebar() { // 只匹配桌面侧栏容器,不碰移动端菜单。 for (const sidebar of document.querySelectorAll('.side-bar')) { const container = sidebar.parentElement; if (!container) continue; if (!container.classList.contains('lg:block')) continue; const siblings = [...(container.parentElement?.children || [])]; const hasContent = siblings.some(el => el.classList.contains('dashboard-content') ); if (hasContent) return container; } return null; } function update() { const sidebar = findDesktopSidebar(); button.hidden = !desktop.matches || !sidebar; if (!sidebar) return; sidebar.classList.add(MARKER); // 小窗口恢复网站自身的响应式控制。 sidebar.classList.toggle( COLLAPSED, desktop.matches && collapsed ); button.textContent = collapsed ? '展开侧栏 ›' : '‹ 收起侧栏'; button.setAttribute('aria-expanded', String(!collapsed)); button.title = collapsed ? '展开左侧导航栏' : '隐藏左侧导航栏,增加内容显示空间'; } button.addEventListener('click', () => { collapsed = !collapsed; try { sessionStorage.setItem( STORAGE_KEY, collapsed ? '1' : '0' ); } catch {} update(); }); desktop.addEventListener('change', update); // 网站切换页面时可能重建侧栏,需要重新识别。 // 只观察节点增删,不观察我们修改的class,避免循环触发。 let queued = false; const observer = new MutationObserver(records => { const relevant = records.some(record => record.target !== button && [...record.addedNodes, ...record.removedNodes] .some(node => node.nodeType === Node.ELEMENT_NODE) ); if (!relevant || queued) return; queued = true; requestAnimationFrame(() => { queued = false; update(); }); }); observer.observe(document.body, { childList: true, subtree: true }); update(); })(); if (document.getElementById(PANEL_ID)) return; const host = document.createElement('div'); host.id = PANEL_ID; host.style.cssText = ` position:fixed;right:16px;top:110px; z-index:999999;width:300px;max-width:calc(100vw - 24px); `; document.body.appendChild(host); const root = host.attachShadow({ mode: 'open' }); root.innerHTML = `
城市网段查询与测试
输入国家、城市后查询。
仅检测网段起始地址,不代表整个网段。 IPPure系数越低越好。 查询城市会重新加载地区页,重置未提交选择,不会下单。
`; const $ = id => root.getElementById(id); const status = message => { $('status').textContent = message; }; let searching = false; let testing = false; let cancelled = false; const resultBoxes = new WeakMap(); function updateButtons() { $('open').disabled = searching || testing; $('test').disabled = searching || testing; $('stop').hidden = !testing; } function exact(name, selector = 'button,h1,h2,h3,p,span,div') { return [...document.querySelectorAll(selector)] .find(el => visible(el) && norm(el.textContent) === norm(name) && ![...el.children].some(child => norm(child.textContent) === norm(name) ) ); } async function waitFor(test, message, timeout = 15000) { const deadline = Date.now() + timeout; while (Date.now() < deadline) { const result = test(); if (result) return result; await sleep(250); } throw new Error(message); } function segmentPage() { // 包含p标签,修复之前“已进入网段页仍无法测试”的问题。 return !!exact('选择网段', 'h1,h2,h3,p,span,div'); } function currentItems() { if (!segmentPage()) return []; const items = []; const seen = new Set(); for (const el of document.querySelectorAll('div,span,p,td')) { if (!visible(el) || el.children.length) continue; if (el.closest('[data-b2-ippure-result]')) continue; const cidr = text(el); if (!/^(?:\d{1,3}\.){3}\d{1,3}\/\d{1,2}$/.test(cidr)) continue; const [ip, mask] = cidr.split('/'); if ( Number(mask) > 32 || ip.split('.').some(part => Number(part) > 255) || seen.has(cidr) ) continue; seen.add(cidr); items.push({ el, cidr, ip }); } return items; } function cardInfo(card) { const title = card.innerText .split('$')[0] .replace(/\s+/g, ' ') .trim(); const comma = title.search(/[,,]/); if (comma < 0) return null; return { card, title, city: title.slice(0, comma).trim(), country: title.slice(comma + 1) .trim() .split(/\s+[-–—]\s+/)[0] .trim() }; } async function openCard(info) { if (searching || testing) return; searching = true; updateButtons(); $('choices').replaceChildren(); try { if (!info.card.isConnected) { throw new Error('页面已变化,请重新查询。'); } status(`正在打开:${info.title}`); info.card.scrollIntoView({ block: 'center' }); info.card.click(); await waitFor( () => info.card.querySelector('input'), '城市未成功选中,请检查页面弹窗。' ); const button = await waitFor( () => exact('自选网段', 'button'), '未找到“自选网段”按钮。' ); if (button.disabled) { throw new Error('自选网段不可用,可能暂无库存。'); } // 只打开网段页,不点击订购按钮。 button.click(); await waitFor(segmentPage, '未进入网段页面。'); await waitFor( () => currentItems().length, '未读到网段,可能暂无库存或页面结构已变化。' ); status( `${info.title}\n已读取 ${currentItems().length} 个网段。` ); } catch (error) { status(`查询失败:${error.message}`); } finally { searching = false; updateButtons(); } } async function search(country, city) { searching = true; updateButtons(); status(`正在查找:${country} / ${city}`); let singleMatch = null; try { await waitFor( () => [...document.querySelectorAll('li.area-item')] .some(visible), '未找到地区列表,请确认已登录并关闭网站弹窗。' ); const cards = [...document.querySelectorAll('li.area-item')] .filter(visible) .map(cardInfo) .filter(Boolean); const matches = cards.filter(info => norm(info.country) === norm(country) && norm(info.city) === norm(city) ); $('choices').replaceChildren(); if (!matches.length) { const cities = [...new Set( cards .filter(info => norm(info.country) === norm(country)) .map(info => info.city) )]; status( `当前列表未找到:${country} / ${city}\n` + (cities.length ? `可选城市/地区:\n${cities.join('、')}` : '请使用网站上的国家和城市名称。') ); } else if (matches.length === 1) { singleMatch = matches[0]; } else { status('找到多个选项,请选择线路:'); for (const info of matches) { const button = document.createElement('button'); button.type = 'button'; button.className = 'choice'; button.textContent = info.title; button.onclick = () => openCard(info); $('choices').appendChild(button); } } } catch (error) { status(`查询失败:${error.message}`); } finally { searching = false; updateButtons(); } if (singleMatch) await openCard(singleMatch); } /* * 卡片结果:原生 · 住宅 查看 * 下一行显示分数;不重复显示地址和字段名。 */ function show(item, result = null, message = '', error = false) { if (!item.el.isConnected) return; const card = item.el.parentElement; const grid = card.parentElement; // 原网站卡片固定78px,检测结果会溢出。 for (const sibling of grid.children) { if (!(sibling instanceof HTMLElement)) continue; sibling.style.setProperty('height', 'auto', 'important'); sibling.style.setProperty('min-height', '136px', 'important'); sibling.style.setProperty('align-self', 'stretch', 'important'); } item.el.style.setProperty('flex', '0 0 auto', 'important'); item.el.style.setProperty('min-height', '38px', 'important'); let box = resultBoxes.get(item.el); if (!box?.isConnected) { box = document.createElement('div'); box.dataset.b2IppureResult = '1'; item.el.after(box); resultBoxes.set(item.el, box); } box.style.cssText = ` box-sizing:border-box;width:100%;flex:0 0 auto; min-height:52px;padding:7px 4px; display:flex;flex-direction:column; align-items:center;justify-content:center; text-align:center;font-size:12px;line-height:19px; overflow-wrap:anywhere; background:${error ? '#fff1f1' : '#eef6ff'}; color:${error ? '#a12727' : '#23466b'}; `; box.title = '仅代表此网段起始地址,不代表整个网段。'; box.replaceChildren(); const firstLine = document.createElement('div'); firstLine.style.cssText = ` display:flex;align-items:center;justify-content:center; flex-wrap:wrap;gap:5px; `; const label = document.createElement('span'); const simplify = value => String(value || '') .replace(/IP/gi, '') .replace(/\s+/g, ' ') .trim(); label.textContent = result ? `${simplify(result.source)} · ${simplify(result.attribute)}` : message; const link = document.createElement('a'); link.textContent = '查看'; link.href = 'https://ippure.com/?ip=' + encodeURIComponent(item.ip); link.target = '_blank'; link.rel = 'noopener noreferrer'; link.style.cssText = ` color:#2563eb;text-decoration:underline; cursor:pointer;font-size:12px;white-space:nowrap; `; link.addEventListener('click', event => event.stopPropagation()); firstLine.append(label, link); box.appendChild(firstLine); if (result) { const scoreLine = document.createElement('div'); scoreLine.textContent = `${result.score}% · ${result.rating}`; box.appendChild(scoreLine); } const footer = box.nextElementSibling; if (footer) { footer.style.setProperty('flex', '1 0 34px', 'important'); footer.style.setProperty('min-height', '34px', 'important'); } } async function queryIP(item) { const token = crypto.randomUUID(); const key = JOB_PREFIX + token; let tab; await GM_setValue(key, { ip: item.ip, created: Date.now() }); try { tab = GM_openInTab( `https://ippure.com/?ip=${encodeURIComponent(item.ip)}` + `#b2test=${encodeURIComponent(token)}`, { active: false, insert: true } ); const deadline = Date.now() + 90000; while (Date.now() < deadline) { if (cancelled) throw new Error('已停止测试。'); if (!item.el.isConnected) { throw new Error('页面已切换,请重新开始测试。'); } const result = await GM_getValue(key + '-result', null); if (result) { if (!result.ok) throw new Error(result.error); if (result.ip !== item.ip) { throw new Error('返回IP与请求IP不一致。'); } tab?.close(); return result; } if (tab?.closed) throw new Error('查询标签页被关闭。'); await sleep(500); } throw new Error('查询超时,请查看IPPure标签页是否需要验证。'); } finally { // 失败时保留标签页,方便人工检查。 await GM_deleteValue(key); await GM_deleteValue(key + '-result'); } } $('open').onclick = () => { if (searching || testing) return; const country = $('country').value.trim(); const city = $('city').value.trim(); if (!country || !city) { status('请同时填写国家和城市。'); return; } try { sessionStorage.setItem(SEARCH_KEY, JSON.stringify({ country, city, time: Date.now() })); // 重新加载地区页,避免叠加此前选择。 location.assign(PAGE); } catch (error) { status(`无法启动查询:${error.message}`); } }; $('copy').onclick = async () => { const items = currentItems(); if (!items.length) { status('请先打开某个城市的“自选网段”。'); return; } const values = [...new Set( items.map(item => $('format').value === 'cidr' ? item.cidr : item.ip ) )]; try { await GM_setClipboard(values.join('\n'), 'text'); status(`已复制 ${values.length} 行到剪贴板。`); } catch (error) { status(`复制失败:${error.message}`); } }; $('stop').onclick = () => { cancelled = true; status('正在停止测试……'); }; $('test').onclick = async () => { if (testing || searching) return; const items = currentItems(); if (!items.length) { status('请先打开某个城市的“自选网段”。'); return; } const confirmed = confirm( `将向IPPure发送 ${items.length} 个网段起始地址,` + '并逐个打开查询标签页。\n\n' + '检测结果不代表整个网段,也不代表实际分配的代理出口。\n\n' + '是否开始?' ); if (!confirmed) return; testing = true; cancelled = false; updateButtons(); let done = 0; try { for (const item of items) { if (cancelled) break; if (!item.el.isConnected) { throw new Error('页面已切换,请重新测试。'); } status(`正在测试 ${done + 1}/${items.length}:${item.ip}`); show(item, null, '检测中…'); let result; try { result = await queryIP(item); } catch (error) { show(item, null, error.message, true); throw error; } show(item, result); done++; if (done < items.length) { // 顺序查询,留出间隔,不并发请求。 for (let i = 0; i < 8 && !cancelled; i++) { await sleep(500); } } } status( cancelled ? `已停止,完成 ${done}/${items.length} 项。` : `测试完成,共 ${done} 项。` ); } catch (error) { status( `已停止,完成 ${done}/${items.length} 项。\n${error.message}` ); } finally { testing = false; updateButtons(); } }; $('toggle').onclick = () => { $('body').hidden = !$('body').hidden; $('toggle').textContent = $('body').hidden ? '展开' : '收起'; }; /* * 新增:复制该国家所有地区的IP / 网段。 * 范围:家宽原生。 * 每次重新加载地区页,避免不同地区的选择互相叠加。 */ const COUNTRY_JOB = 'b2-country-copy-job-v1'; const COUNTRY_PARTIAL = 'b2-country-copy-partial-v1'; const countryCopyButton = document.createElement('button'); countryCopyButton.id = 'copy-country'; countryCopyButton.type = 'button'; countryCopyButton.className = 'wide'; countryCopyButton.textContent = '收集该国家所有 IP / 网段'; $('test').before(countryCopyButton); const countryStopButton = document.createElement('button'); countryStopButton.type = 'button'; countryStopButton.className = 'wide'; countryStopButton.textContent = '停止收集'; countryStopButton.style.background = '#b94b45'; countryStopButton.hidden = true; countryCopyButton.after(countryStopButton); let countryCancelled = false; // 完整收集结果单独保存。 // 与正在运行的任务分开,新的任务失败也不清空上次完整结果。 const COUNTRY_RESULT = 'b2-country-collected-result-v2'; const collectedArea = document.createElement('div'); collectedArea.style.cssText = ` margin-top:12px; padding-top:10px; border-top:1px solid #e5e7eb; `; const collectedInfo = document.createElement('div'); collectedInfo.style.cssText = ` font-size:12px; color:#687385; white-space:pre-wrap; overflow-wrap:anywhere; `; const copyCollectedButton = document.createElement('button'); copyCollectedButton.type = 'button'; copyCollectedButton.className = 'wide'; copyCollectedButton.textContent = '复制收集的 IP / 网段'; collectedArea.append(collectedInfo, copyCollectedButton); const openBatchTestButton = document.createElement('button'); openBatchTestButton.type = 'button'; openBatchTestButton.className = 'wide'; openBatchTestButton.textContent = '批量测试 IP'; openBatchTestButton.style.background = '#059669'; // 独立入口不能放在 collectedArea 内,否则没有收集结果时会被隐藏。 $('body').appendChild(openBatchTestButton); // 放在面板底部。 $('body').appendChild(collectedArea); /* * 自定义 IP 批量检测悬浮窗口:不依赖网段页,也不影响旧版一键测试。 * 检测仍由 IPPure 网页执行,抓取到的数据来自网站而不是本脚本推算。 */ const BATCH_STORAGE = 'b2-custom-batch-v1'; const batchHost = document.createElement('div'); batchHost.id = 'b2-custom-ip-batch'; batchHost.style.cssText = 'position:fixed;right:28px;top:65px;z-index:2147483645;width:min(820px,calc(100vw - 24px));display:none;'; document.body.appendChild(batchHost); const batchRoot = batchHost.attachShadow({mode:'open'}); batchRoot.innerHTML = `
IP 批量检测 · IPPure + IP2Location
IP 地址0 个 IP
每行一个 IPv4,自动去重;支持粘贴带 /24 的网段(仅检测起始地址)。网页结果不代表整个网段。
当前状态:待开始
测试进度0 / 0 (0%)
0成功
0失败
0待检测
待开始测试
检测结果双数据源独立查询;无法获取的字段显示 —
IP 地址国家/地区IP 属性IP 来源纯净度系数风险等级AS Usage TypeFraud ScoreAnonymous Proxy检测状态
`; const B = id => batchRoot.getElementById(id); let batch = {input:'',rows:[]}; let batchRunning = false; let batchStopRequested = false; let batchPaused = false; let batchAction = ''; // pause / stop let batchPersistQueue = Promise.resolve(); const isIPv4 = value => { const parts = value.split('.'); return parts.length === 4 && parts.every(x => /^\d{1,3}$/.test(x) && Number(x) <= 255); }; function parseBatchInput(value) { const entries = value.split(/[\s,;,;]+/).map(x => x.trim()).filter(Boolean); const unique = new Set(), valid = [], invalid = []; for (const entry of entries) { const match = entry.match(/^((?:\d{1,3}\.){3}\d{1,3})(?:\/(\d{1,2}))?$/); if (!match || !isIPv4(match[1]) || (match[2] !== undefined && Number(match[2]) > 32)) { invalid.push(entry);continue; } if (!unique.has(match[1])) {unique.add(match[1]);valid.push(match[1]);} } return {valid, invalid}; } function persistBatch() { const snapshot = JSON.stringify(batch); batchPersistQueue = batchPersistQueue.catch(() => {}).then(() => GM_setValue(BATCH_STORAGE, snapshot)); return batchPersistQueue; } function syncBatchInput() { const parsed = parseBatchInput(B('ips').value); const old = new Map(batch.rows.map(row => [row.ip,row])); batch.input = B('ips').value; batch.rows = parsed.valid.map(ip => normalizeBatchRow(old.get(ip) || {ip,status:'pending'})); return parsed; } // 兼容 2.1.4 的已保存结果:IPPure 和 AS Usage Type 不会丢失, // 旧记录只有新增的 Fraud Score / Anonymous Proxy 处于待补测状态。 function normalizeBatchRow(row) { if (!row.ippureStatus) row.ippureStatus = row.status === 'success' ? 'success' : row.status === 'error' ? 'error' : 'pending'; if (!row.asUsageStatus) row.asUsageStatus = 'pending'; if (!row.asUsageType) row.asUsageType = '—'; if (!row.ip2ExtraStatus) row.ip2ExtraStatus = 'pending'; if (row.fraudScore === undefined || row.fraudScore === null || row.fraudScore === '') row.fraudScore = '—'; if (!row.anonymousProxy) row.anonymousProxy = '—'; if (row.geoChecked !== true && row.ippureStatus === 'success' && row.country && row.country !== '—') row.geoChecked = true; return row; } function rowIsDone(row) { return ['success','error'].includes(row.ippureStatus) && ['success','error'].includes(row.asUsageStatus) && ['success','error'].includes(row.ip2ExtraStatus); } function renderBatch(message) { const total = batch.rows.length; const done = batch.rows.filter(rowIsDone).length; const success = batch.rows.filter(row => row.ippureStatus === 'success' && row.asUsageStatus === 'success' && row.ip2ExtraStatus === 'success').length; const failure = batch.rows.filter(row => row.ippureStatus === 'error' || row.asUsageStatus === 'error' || row.ip2ExtraStatus === 'error').length; B('count').textContent = `${total} 个 IP`; B('success').textContent = success; B('failure').textContent = failure; B('pending').textContent = total - done; B('progress-text').textContent = `${done} / ${total} (${total ? Math.round(done / total * 100) : 0}%)`; B('progress-bar').style.width = `${total ? done / total * 100 : 0}%`; B('start').hidden = batchRunning || batchPaused; B('start').disabled = !total || batch.rows.every(row => row.ippureStatus === 'success' && row.asUsageStatus === 'success' && row.ip2ExtraStatus === 'success' && row.geoChecked === true); B('pause').hidden = !batchRunning; B('pause').disabled = !!batchAction; B('pause').textContent = batchAction === 'pause' ? '暂停中…' : '暂停测试'; B('resume').hidden = !batchPaused; B('stop').hidden = !batchRunning && !batchPaused; B('stop').disabled = batchAction === 'stop'; B('stop').textContent = batchAction === 'stop' ? '停止中…' : '停止测试'; B('reset').disabled = batchRunning || batchPaused; B('ips').disabled = batchRunning || batchPaused; B('run-state').textContent = '当前状态:' + ( batchAction === 'stop' ? '正在停止' : batchRunning && batchAction === 'pause' ? '正在暂停,等待当前 IP 的双源检测结束' : batchRunning ? '检测中(IPPure 与 IP2Location 独立进行)' : batchPaused ? '已暂停,可继续或停止' : '未运行' ); B('results').replaceChildren(); for (const row of batch.rows) { normalizeBatchRow(row); const tr = document.createElement('tr'); const iptd = document.createElement('td'); const link = document.createElement('a'); link.href = 'https://ippure.com/?ip=' + encodeURIComponent(row.ip); link.target = '_blank';link.rel = 'noopener noreferrer';link.textContent = row.ip; iptd.appendChild(link);tr.appendChild(iptd); const pureOK = row.ippureStatus === 'success'; const usageOK = row.asUsageStatus === 'success'; const extraOK = row.ip2ExtraStatus === 'success'; const ip2OK = usageOK && extraOK; const busy = ['running'].some(v => [row.ippureStatus,row.asUsageStatus,row.ip2ExtraStatus].includes(v)); const state = busy ? '检测中…' : rowIsDone(row) ? (pureOK && ip2OK ? '完成' : pureOK || usageOK || extraOK ? '部分完成' : '均失败') : [row.ippureStatus,row.asUsageStatus,row.ip2ExtraStatus].includes('error') ? '部分待检测' : '待检测'; // 已有字段在重新补测中也保留显示,不能因新字段失败而清空旧结果。 const fields = [ pureOK ? row.country || '—' : '—', pureOK ? row.attribute || '—' : '—', pureOK ? row.source || '—' : '—', pureOK ? `${row.score}%` : '—', pureOK ? row.rating || '—' : '—', row.asUsageType || '—', String(row.fraudScore ?? '—'), row.anonymousProxy || '—', state ]; fields.forEach((value,index) => { const td = document.createElement('td'); if (index === 6) { // 不论有无评分,点击都能打开该 IP 的完整 IP2Location 页面。 const scoreLink = document.createElement('a'); scoreLink.href = 'https://www.ip2location.com/' + encodeURIComponent(row.ip); scoreLink.target = '_blank'; scoreLink.rel = 'noopener noreferrer'; scoreLink.textContent = value; scoreLink.title = '在 IP2Location 查看该 IP 的 Fraud Score 与详细信息'; td.appendChild(scoreLink); } else td.textContent = value; if (index === 4 && pureOK) td.className = /风险/.test(value) ? 'bad' : 'good'; if (index === 8 && rowIsDone(row) && !(pureOK && ip2OK)) td.className = 'bad'; if (index === 5 && !usageOK && row.asUsageError) td.title = 'IP2Location:' + row.asUsageError; if ([6,7].includes(index) && !extraOK && row.ip2ExtraError) td.title = 'IP2Location:' + row.ip2ExtraError; if (index === 8) td.title = [row.ippureError && 'IPPure:' + row.ippureError, row.asUsageError && 'IP2Location AS Usage Type:' + row.asUsageError, row.ip2ExtraError && 'IP2Location 扩展字段:' + row.ip2ExtraError].filter(Boolean).join(';'); tr.appendChild(td); }); B('results').appendChild(tr); } if (message !== undefined) B('message').textContent = message; } async function queryProvider(ip, provider) { const isPure = provider === 'ippure'; const token = crypto.randomUUID(); const key = (isPure ? JOB_PREFIX : AS_USAGE_JOB_PREFIX) + token; const url = isPure ? `https://ippure.com/?ip=${encodeURIComponent(ip)}#b2test=${encodeURIComponent(token)}` : `https://www.ip2location.com/${encodeURIComponent(ip)}#b2asusage=${encodeURIComponent(token)}`; let tab; await GM_setValue(key,{ip,created:Date.now()}); try { tab = GM_openInTab(url,{active:false,insert:true}); const deadline = Date.now() + 90000; while (Date.now() < deadline) { if (batchAction === 'stop') { try {tab?.close();} catch {} throw new Error('用户停止检测'); } const result = await GM_getValue(key + '-result',null); if (result) { if (!result.ok) throw new Error(result.error || '检测失败'); if (result.ip !== ip) throw new Error('返回 IP 不一致'); try {tab?.close();} catch {} return result; } if (tab?.closed) throw new Error('检测标签页被关闭'); await sleep(500); } throw new Error((isPure ? 'IPPure' : 'IP2Location') + ' 查询超时,可能需要验证'); } finally { await GM_deleteValue(key); await GM_deleteValue(key + '-result'); // 失败时保留标签页,便于人工检查。 } } async function runBatch(resuming = false) { if (batchRunning) return; if (!resuming) { const parsed = syncBatchInput(); if (parsed.invalid.length) { renderBatch(`已发现 ${parsed.invalid.length} 个无效输入,请修正:${parsed.invalid.slice(0,5).join('、')}`); return; } } if (!batch.rows.length) {renderBatch('请先粘贴至少一个有效 IP。');return;} batch.rows.forEach(normalizeBatchRow); batchAction = ''; batchPaused = false; batchStopRequested = false; batchRunning = true; renderBatch(resuming ? '继续检测尚未成功的数据源…' : '开始双源批量检测…'); let fatalError = ''; try { await persistBatch(); for (const row of batch.rows) { if (batchAction) break; const providers = [ {id:'ippure',field:'ippureStatus',errorField:'ippureError'}, {id:'asUsage',field:'asUsageStatus',errorField:'asUsageError'} ].filter(p => p.id === 'ippure' ? row.ippureStatus !== 'success' || row.geoChecked !== true : row.asUsageStatus !== 'success' || row.ip2ExtraStatus !== 'success'); if (!providers.length) continue; const prevStatuses = { ippureStatus: row.ippureStatus, asUsageStatus: row.asUsageStatus, ip2ExtraStatus: row.ip2ExtraStatus }; for (const p of providers) { if (p.id === 'ippure') { row.ippureStatus = 'running'; delete row.ippureError; } else { if (row.asUsageStatus !== 'success') row.asUsageStatus = 'running'; if (row.ip2ExtraStatus !== 'success') row.ip2ExtraStatus = 'running'; delete row.asUsageError; delete row.ip2ExtraError; } } renderBatch(`正在查询 ${row.ip} · ${providers.map(p => p.id === 'ippure' ? 'IPPure' : 'IP2Location').join(' + ')}`); await persistBatch(); // 两个网站独立完成、独立保存;IP2Location 页面读取 3 个字段。 await Promise.all(providers.map(async provider => { try { const result = await queryProvider(row.ip,provider.id); if (provider.id === 'ippure') { Object.assign(row,{country:result.country || '—',source:result.source, attribute:result.attribute,score:result.score,rating:result.rating,geoChecked:true}); row.ippureStatus = 'success'; } else { // 保留旧版已取得的 AS Usage Type;缺失的字段独立显示“—”。 if (result.asUsageType) row.asUsageType = result.asUsageType; if (result.fraudScore !== undefined && result.fraudScore !== '') row.fraudScore = result.fraudScore; if (result.anonymousProxy) row.anonymousProxy = result.anonymousProxy; row.ip2ExtraStatus = 'success'; // 页面已读取,新字段如未显示则保持“—”。 if (result.asUsageType || prevStatuses.asUsageStatus === 'success') { row.asUsageStatus = 'success'; } else { row.asUsageStatus = 'error'; row.asUsageError = '该页面未提供 AS Usage Type'; } } } catch (err) { const reason = err?.message || String(err); const stopped = batchAction === 'stop'; if (provider.id === 'ippure') { row.ippureStatus = prevStatuses.ippureStatus === 'success' ? 'success' : stopped ? 'pending' : 'error'; row.ippureError = reason; } else { row.asUsageStatus = prevStatuses.asUsageStatus === 'success' ? 'success' : stopped ? 'pending' : 'error'; row.ip2ExtraStatus = prevStatuses.ip2ExtraStatus === 'success' ? 'success' : stopped ? 'pending' : 'error'; row.asUsageError = reason; row.ip2ExtraError = reason; } } finally { // 单个来源完成就保存和刷新,不覆盖其他来源已完成的数据。 try {await persistBatch();} catch(e) {console.warn('保存单项结果失败',e);} renderBatch(`检测中:${row.ip}(数据源独立保存)`); } })); if (batchAction) break; for (let i=0;i<8 && !batchAction;i++) await sleep(500); } } catch (error) { fatalError = error?.message || String(error); console.error('[B2Proxy IP批量检测] 任务异常:',error); } finally { const action = batchAction; batchRunning = false; batchPaused = action === 'pause'; batchAction = ''; batchStopRequested = false; for (const row of batch.rows) { if (row.ippureStatus === 'running') row.ippureStatus = 'pending'; if (row.asUsageStatus === 'running') row.asUsageStatus = 'pending'; if (row.ip2ExtraStatus === 'running') row.ip2ExtraStatus = 'pending'; } try {await persistBatch();} catch (error) {fatalError ||= '保存结果失败:' + (error?.message || error);} renderBatch(action === 'pause' ? '已暂停,两个来源的已完成结果都保留。点击「继续测试」继续。' : action === 'stop' ? '已停止。数据保留,按钮已恢复「开始测试」。' : fatalError ? '检测任务异常结束:' + fatalError : '本轮测试结束。失败来源可点击「开始测试」单独重试。'); } } B('start').onclick = () => {void runBatch(false);}; B('pause').onclick = () => { if (!batchRunning || batchAction) return; batchAction = 'pause'; renderBatch('正在暂停:当前 IP 检测完成后暂停,不再检测下一项。'); }; B('resume').onclick = () => { if (!batchPaused || batchRunning) return; void runBatch(true); }; B('stop').onclick = () => { if (batchRunning) { batchAction = 'stop'; renderBatch('正在停止当前检测,已完成结果不会删除…'); } else if (batchPaused) { batchPaused = false; batchAction = ''; renderBatch('已停止。已完成结果保留;按钮恢复为「开始测试」。'); } }; B('ips').addEventListener('input',() => { syncBatchInput();renderBatch();void persistBatch(); }); B('reset').onclick = async () => { if (batchRunning || batchPaused || !confirm('清空输入和全部已保存的批量检测结果?')) return; batch = {input:'',rows:[]};B('ips').value=''; await persistBatch();renderBatch('已清空。'); }; function exportRows() { const quote = value => '"' + String(value ?? '').replace(/"/g,'""') + '"'; const header = ['IP地址','国家/地区','IP属性','IP来源','IPPure系数','风险等级','AS Usage Type','Fraud Score','Anonymous Proxy','IPPure状态','AS Usage Type状态','扩展字段状态','错误信息']; const lines = batch.rows.map(row => [row.ip,row.country || '',row.attribute || '',row.source || '', row.ippureStatus === 'success' ? `${row.score}%` : '',row.rating || '', row.asUsageType || '—',String(row.fraudScore ?? '—'),row.anonymousProxy || '—', row.ippureStatus,row.asUsageStatus,row.ip2ExtraStatus, [row.ippureError && 'IPPure: ' + row.ippureError, row.asUsageError && 'IP2Location AS Usage Type: ' + row.asUsageError, row.ip2ExtraError && 'IP2Location Fraud Score/Anonymous Proxy: ' + row.ip2ExtraError].filter(Boolean).join('; ')]); return [header,...lines].map(line => line.map(quote).join(',')).join('\r\n'); } B('copy').onclick = async () => { if (!batch.rows.length) return; try {await GM_setClipboard(exportRows(),'text');renderBatch('已复制 CSV 格式的检测结果。');} catch(err){renderBatch(`复制失败:${err.message}`);} }; B('export').onclick = () => { if (!batch.rows.length) return; const blob = new Blob(['\ufeff' + exportRows()],{type:'text/csv;charset=utf-8'}); const url = URL.createObjectURL(blob); const a = document.createElement('a');a.href=url;a.download='IP批量检测_IPPure_IP2Location.csv'; a.style.display='none';batchRoot.appendChild(a);a.click();a.remove(); setTimeout(()=>URL.revokeObjectURL(url),15000); }; B('close').onclick = () => {batchHost.style.display='none';}; openBatchTestButton.onclick = () => {batchHost.style.display='block';renderBatch();}; // 可拖拽标题栏;小屏时限制悬浮窗口保持在可视区域。 let drag = null; B('drag').addEventListener('pointerdown',e => { if (e.target.closest('button') || e.button !== 0) return; const rect = batchHost.getBoundingClientRect(); drag = {x:e.clientX,y:e.clientY,left:rect.left,top:rect.top}; B('drag').setPointerCapture(e.pointerId); }); B('drag').addEventListener('pointermove',e => { if (!drag) return; const left = Math.max(0,Math.min(innerWidth-batchHost.offsetWidth,drag.left+e.clientX-drag.x)); const top = Math.max(0,Math.min(innerHeight-50,drag.top+e.clientY-drag.y)); batchHost.style.left=`${left}px`;batchHost.style.top=`${top}px`;batchHost.style.right='auto'; }); B('drag').addEventListener('pointerup',()=>{drag=null;}); B('drag').addEventListener('pointercancel',()=>{drag=null;}); try { const saved = await GM_getValue(BATCH_STORAGE,null); if (saved) { const data = JSON.parse(saved); if (typeof data.input === 'string' && Array.isArray(data.rows)) { batch = data; batch.rows = batch.rows.map(row => {normalizeBatchRow(row); if (row.ippureStatus === 'running') row.ippureStatus = 'pending'; if (row.asUsageStatus === 'running') row.asUsageStatus = 'pending'; if (row.ip2ExtraStatus === 'running') row.ip2ExtraStatus = 'pending'; return row;}); B('ips').value = batch.input; syncBatchInput(); } } } catch (err) {console.warn('读取批量 IP 历史记录失败',err);} renderBatch('就绪。暂停后才显示「继续测试」;停止后恢复「开始测试」。已完成结果会保留。'); function readCollectedResult() { try { const raw = sessionStorage.getItem(COUNTRY_RESULT); if (!raw) return null; const result = JSON.parse(raw); if ( !Array.isArray(result.segments) || !result.segments.length ) return null; return result; } catch { return null; } } function refreshCollectedArea() { const result = readCollectedResult(); collectedArea.hidden = !result; copyCollectedButton.disabled = !result; if (!result) return; // 之前版本保存的完整结果没有complete字段, // 因此只有明确为false时才标记为部分收集。 const partial = result.complete === false; const regionText = partial ? `${result.regionCount}/${result.totalRegions} 个地区/线路` : `${result.regionCount} 个地区/线路`; collectedInfo.textContent = `${partial ? '部分收集' : '收集完成'}:${result.country} · 家宽原生\n` + `${regionText},${result.segments.length} 个不同网段\n` + `保存时间:${new Date(result.finishedAt).toLocaleString()}\n` + ( partial ? '可复制当前已收集的全部网段,不代表该国完整列表。' : '结果已保留,可随时复制。' ); copyCollectedButton.textContent = `复制收集的 IP / 网段(${result.segments.length})`; // 结果出现后,自动滚动面板,让复制按钮进入可见区域。 requestAnimationFrame(() => { copyCollectedButton.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }); } copyCollectedButton.onclick = async () => { const result = readCollectedResult(); if (!result) { refreshCollectedArea(); status('暂无收集结果,请先收集。'); return; } const format = $('format').value; const values = [...new Set( result.segments.map(segment => format === 'cidr' ? segment : segment.split('/')[0] ) )]; copyCollectedButton.disabled = true; copyCollectedButton.textContent = '正在复制…'; try { await GM_setClipboard(values.join('\n'), 'text'); copyCollectedButton.textContent = '✓ 已复制'; collectedInfo.textContent = `已复制:${result.country} · ${values.length} 行\n` + `格式:${format === 'cidr' ? '完整网段' : '网段起始地址'}\n` + '收集结果仍然保留,可随时再次复制。'; } catch (error) { copyCollectedButton.textContent = '复制失败'; collectedInfo.textContent = `复制失败:${error.message}\n` + '收集结果仍然保留,可以重试。'; } finally { setTimeout(() => { const saved = readCollectedResult(); copyCollectedButton.textContent = saved ? `复制收集的 IP / 网段(${saved.segments.length})` : '复制收集的 IP / 网段'; copyCollectedButton.disabled = !saved; }, 1500); } }; // 刷新页面后,恢复上次已经完成的收集结果。 refreshCollectedArea(); function saveCountryJob(job) { job.updated = Date.now(); sessionStorage.setItem(COUNTRY_JOB, JSON.stringify(job)); } function ensureCountryRunning() { if (countryCancelled) { throw new Error('已停止收集。'); } } function countryLock() { // 同时阻止原来的城市查询和一键测试。 searching = true; updateButtons(); countryCopyButton.disabled = true; countryStopButton.hidden = false; $('copy').disabled = true; $('country').disabled = true; $('city').disabled = true; $('format').disabled = true; } function countryUnlock() { searching = false; updateButtons(); countryCopyButton.disabled = false; countryStopButton.hidden = true; $('copy').disabled = false; $('country').disabled = false; $('city').disabled = false; $('format').disabled = false; } countryStopButton.onclick = () => { countryCancelled = true; sessionStorage.removeItem(COUNTRY_JOB); status('正在停止收集……'); }; async function waitCountry(test, message, timeout = 15000) { const deadline = Date.now() + timeout; while (Date.now() < deadline) { ensureCountryRunning(); const result = test(); if (result) return result; await sleep(250); } throw new Error(message); } function countryCards(country) { return [...document.querySelectorAll('li.area-item')] .filter(visible) .map(cardInfo) .filter(Boolean) .filter(info => norm(info.country) === norm(country)); } async function copyCountryResult(job) { const segments = [...new Set(job.segments)]; if (!segments.length) { throw new Error('没有收集到任何网段,未保存新结果。'); } const result = { country: job.country, category: '家宽原生', regionCount: job.targets.length, segments, finishedAt: Date.now() }; // 只保存数据,绝不自动写入剪贴板。 // 保存成功后才清理正在运行的任务。 sessionStorage.setItem( COUNTRY_RESULT, JSON.stringify(result) ); sessionStorage.removeItem(COUNTRY_JOB); sessionStorage.removeItem(COUNTRY_PARTIAL); refreshCollectedArea(); status( `收集完成:${job.country} · 家宽原生\n` + `完成 ${result.regionCount} 个地区/线路选项。\n` + `去重后共 ${segments.length} 个网段。\n` + '结果已保存,没有修改剪贴板。\n' + '需要时点击底部“复制收集的 IP / 网段”。' ); } async function collectCountryStep(job) { countryLock(); $('country').value = job.country; $('format').value = job.format; try { ensureCountryRunning(); await waitCountry( () => [...document.querySelectorAll('li.area-item')] .some(visible), '未找到地区列表,请确认已登录并关闭网站弹窗。' ); // 明确使用“家宽原生”,不混入家宽广播。 const nativeTab = exact('家宽原生', 'li,button,span,div'); if (!nativeTab) { throw new Error('未找到“家宽原生”分类,停止以避免读取错误分类。'); } nativeTab.click(); await sleep(1000); ensureCountryRunning(); await waitCountry( () => [...document.querySelectorAll('li.area-item')] .some(visible), '家宽原生地区列表尚未加载。' ); const cards = countryCards(job.country); // 第一轮建立遍历清单,普通线路和双NTT分别保留。 if (!job.targets) { if (!cards.length) { throw new Error( `当前家宽原生列表没有找到国家“${job.country}”。` ); } const keys = cards.map(info => norm(info.title)); // 同名选项无法安全区分时,不擅自漏掉某一个。 if (new Set(keys).size !== keys.length) { throw new Error( '发现名称完全相同的地区选项,无法可靠区分,已停止。' ); } job.targets = cards.map(info => ({ title: info.title, key: norm(info.title) })); job.index = 0; job.segments = []; job.completed = []; saveCountryJob(job); } if (job.index >= job.targets.length) { await copyCountryResult(job); countryUnlock(); return; } const target = job.targets[job.index]; const matches = cards.filter( info => norm(info.title) === target.key ); if (matches.length !== 1) { throw new Error( `地区选项发生变化,无法唯一定位:${target.title}` ); } const info = matches[0]; status( `正在收集 ${job.index + 1}/${job.targets.length}\n` + `${target.title}\n` + `已收集 ${job.segments.length} 个不同网段。` ); info.card.scrollIntoView({ block: 'center' }); ensureCountryRunning(); info.card.click(); await waitCountry( () => info.card.querySelector('input'), `无法选中:${target.title}` ); const button = await waitCountry( () => exact('自选网段', 'button'), `没有找到 ${target.title} 的“自选网段”按钮。` ); if (button.disabled) { throw new Error(`${target.title} 的自选网段不可用。`); } ensureCountryRunning(); // 只打开列表,不提交订单。 button.click(); await waitCountry( segmentPage, `没有进入 ${target.title} 的网段页面。` ); await waitCountry( () => currentItems().length, `${target.title} 未读到网段,可能无库存或页面未加载。` ); // 等待连续多次读到相同列表,降低读取加载中内容的概率。 let previous = ''; let stable = 0; let segments = []; await waitCountry(() => { segments = currentItems().map(item => item.cidr); const signature = segments.join('\n'); if (signature && signature === previous) { stable++; } else { stable = 0; } previous = signature; return stable >= 4; }, `${target.title} 的网段列表未稳定。`); ensureCountryRunning(); job.segments = [ ...new Set([...job.segments, ...segments]) ]; job.completed.push({ title: target.title, count: segments.length }); job.index++; saveCountryJob(job); if (job.index >= job.targets.length) { await copyCountryResult(job); countryUnlock(); return; } status( `已完成 ${job.index}/${job.targets.length}\n` + `已收集 ${job.segments.length} 个不同网段。\n` + '稍后进入下一个地区……' ); // 留出间隔,并允许用户停止。 for (let i = 0; i < 6; i++) { await sleep(500); ensureCountryRunning(); } saveCountryJob(job); location.assign(PAGE); } catch (error) { sessionStorage.removeItem(COUNTRY_JOB); const segments = [...new Set(job.segments || [])]; let saved = false; let saveError = ''; try { if (segments.length > 0) { // 保存本次已经收集到的网段,允许复制部分结果。 sessionStorage.setItem( COUNTRY_RESULT, JSON.stringify({ country: job.country, category: '家宽原生', regionCount: job.index || 0, totalRegions: job.targets?.length || 0, segments, complete: false, finishedAt: Date.now() }) ); saved = true; } } catch (storageError) { saveError = storageError.message; } finally { refreshCollectedArea(); countryUnlock(); } status( `收集已停止:${error.message}\n` + `已完成 ${job.index || 0}/${job.targets?.length || 0} 个地区/线路,` + `已收集 ${segments.length} 个不同网段。\n` + ( saved ? '部分结果已保存,点击底部按钮即可复制。\n没有修改剪贴板。' : saveError ? `保存失败:${saveError}` : '本次尚未收集到网段;如有上次结果,仍然保留。' ) ); } } countryCopyButton.onclick = () => { if (searching || testing) { status('请先等待当前查询/测试结束。'); return; } const country = $('country').value.trim(); if (!country) { status('请先填写国家名称,例如:美国。'); return; } const confirmed = confirm( `将逐个打开“${country}”在“家宽原生”分类下的地区和线路选项,` + '收集所有页面展示的网段,去重后保存,不修改剪贴板。\n\n' + '期间页面会多次刷新,请不要手动切换页面。\n' + '未提交的地区选择会重置,不会下单。\n\n' + '是否开始?' ); if (!confirmed) return; const job = { country, format: $('format').value, targets: null, index: 0, segments: [], completed: [], updated: Date.now() }; try { // 清理其他待执行的城市查询,避免任务冲突。 sessionStorage.removeItem(SEARCH_KEY); sessionStorage.removeItem(COUNTRY_PARTIAL); saveCountryJob(job); countryLock(); status('正在开始收集,请勿手动切换页面……'); location.assign(PAGE); } catch (error) { sessionStorage.removeItem(COUNTRY_JOB); countryUnlock(); status(`无法开始:${error.message}`); } }; // 页面刷新后继续国家批量任务。 // 有国家任务时,跳过后面的普通城市查询恢复逻辑。 let pendingCountryJob = null; try { const raw = sessionStorage.getItem(COUNTRY_JOB); if (raw) { pendingCountryJob = JSON.parse(raw); if (Date.now() - pendingCountryJob.updated > 120000) { sessionStorage.removeItem(COUNTRY_JOB); pendingCountryJob = null; status('上次国家收集任务已过期,请重新点击按钮。'); } } } catch (error) { sessionStorage.removeItem(COUNTRY_JOB); status(`无法读取国家收集任务:${error.message}`); } if (pendingCountryJob && location.pathname === PAGE) { await collectCountryStep(pendingCountryJob); return; } // 刷新到地区页后,接续刚才明确发起的城市查询。 try { const saved = sessionStorage.getItem(SEARCH_KEY); sessionStorage.removeItem(SEARCH_KEY); if (saved && location.pathname === PAGE) { const job = JSON.parse(saved); if (Date.now() - job.time < 60000) { $('country').value = job.country; $('city').value = job.city; await search(job.country, job.city); } } } catch (error) { status(`读取查询任务失败:${error.message}`); } })();