// ==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 = `
`;
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%)
待开始测试
检测结果双数据源独立查询;无法获取的字段显示 —
`;
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}`);
}
})();