// ==UserScript==
// @name HDKylin/AGSVPT SeedBox Batch Registrar
// @namespace https://www.hdkyl.in/
// @namespace https://www.agsvpt.com/
// @version 0.1.0
// @description 为 HDKylin/AGSVPT 用户面板添加 SeedBox 批量登记助手,支持一次性提交多条 IPv4/IPv6 记录。
// @author ai
// @match https://www.hdkyl.in/usercp.php*
// @match https://hdkyl.in/usercp.php*
// @match https://www.agsvpt.com/usercp.php*
// @match https://agsvpt.com/usercp.php*
// @icon https://www.hdkyl.in/favicon.ico
// @grant none
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/556535/HDKylinAGSVPT%20SeedBox%20Batch%20Registrar.user.js
// @updateURL https://update.greasyfork.icu/scripts/556535/HDKylinAGSVPT%20SeedBox%20Batch%20Registrar.meta.js
// ==/UserScript==
(function () {
'use strict'
const SEEDBOX_BUTTON_SELECTOR = '#add-seed-box-btn'
const ACTION_URL = 'ajax.php'
const MODAL_ID = 'seedbox-batch-modal'
const STATUS_ID = 'seedbox-batch-status'
const FORM_ID = 'seedbox-batch-form'
const AUTO_RELOAD_ID = 'seedbox-batch-autoreload'
const state = {
modal: null,
statusBox: null,
form: null,
submitBtn: null,
cancelBtn: null,
}
waitForElement(SEEDBOX_BUTTON_SELECTOR)
.then(anchor => {
if (!anchor || document.getElementById('seedbox-batch-btn')) {
return
}
injectStyles()
createBatchButton(anchor)
createModal()
})
.catch(err => console.warn('[SeedBoxBatch] 初始化失败:', err))
function waitForElement(selector, timeout = 15000) {
return new Promise((resolve, reject) => {
const existing = document.querySelector(selector)
if (existing) {
resolve(existing)
return
}
const observer = new MutationObserver(() => {
const el = document.querySelector(selector)
if (el) {
observer.disconnect()
resolve(el)
}
})
observer.observe(document.documentElement, {childList: true, subtree: true})
setTimeout(() => {
observer.disconnect()
reject(new Error(`元素 ${selector} 超时未出现`))
}, timeout)
})
}
function injectStyles() {
if (document.getElementById('seedbox-batch-style')) {
return
}
const style = document.createElement('style')
style.id = 'seedbox-batch-style'
style.textContent = `
#seedbox-batch-btn {
margin-left: 6px;
padding: 2px 10px;
font-size: 12px;
cursor: pointer;
}
#${MODAL_ID} {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
z-index: 10000;
}
#${MODAL_ID}.is-visible {
display: flex;
}
#${MODAL_ID} .seedbox-modal__dialog {
width: 520px;
max-width: calc(100% - 32px);
background: #fff;
border-radius: 6px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.25);
font-size: 13px;
color: #111;
}
#${MODAL_ID} .seedbox-modal__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #ececec;
font-weight: bold;
}
#${MODAL_ID} .seedbox-modal__close {
border: none;
background: transparent;
font-size: 18px;
cursor: pointer;
}
#${MODAL_ID} form {
padding: 16px;
}
#${MODAL_ID} label {
display: block;
margin-bottom: 10px;
}
#${MODAL_ID} label span {
display: inline-block;
min-width: 90px;
color: #444;
}
#${MODAL_ID} input[type="text"],
#${MODAL_ID} input[type="number"],
#${MODAL_ID} textarea {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
}
#${MODAL_ID} textarea {
min-height: 160px;
resize: vertical;
font-family: Consolas, 'Courier New', monospace;
}
#${MODAL_ID} .seedbox-modal__tips {
font-size: 12px;
color: #777;
margin-top: -6px;
margin-bottom: 12px;
line-height: 1.5;
}
#${MODAL_ID} .seedbox-modal__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
#${MODAL_ID} button.seedbox-btn {
padding: 6px 14px;
border-radius: 4px;
border: 1px solid transparent;
cursor: pointer;
}
#${MODAL_ID} button.seedbox-btn.primary {
background: #4caf50;
color: #fff;
border-color: #449a49;
}
#${MODAL_ID} button.seedbox-btn.secondary {
background: #f1f1f1;
border-color: #d5d5d5;
}
#${MODAL_ID} button.seedbox-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
#${STATUS_ID} {
margin-top: 14px;
max-height: 180px;
overflow-y: auto;
border: 1px solid #ececec;
border-radius: 4px;
padding: 10px;
background: #fafafa;
font-size: 12px;
line-height: 1.5;
}
#${STATUS_ID} ul {
padding-left: 18px;
margin: 0;
}
#${STATUS_ID} li {
margin-bottom: 4px;
}
#${STATUS_ID} li.success {
color: #2e7d32;
}
#${STATUS_ID} li.error {
color: #c62828;
}
#${STATUS_ID} li.skip {
color: #ff9800;
}
#${STATUS_ID} li.info {
color: #1565c0;
}
`
document.head.appendChild(style)
}
function createBatchButton(anchor) {
const batchBtn = document.createElement('input')
batchBtn.type = 'button'
batchBtn.id = 'seedbox-batch-btn'
batchBtn.value = '批量登记'
batchBtn.addEventListener('click', openModal)
anchor.insertAdjacentElement('afterend', batchBtn)
}
function createModal() {
const modal = document.createElement('div')
modal.id = MODAL_ID
modal.innerHTML = `
`
document.body.appendChild(modal)
state.modal = modal
state.form = modal.querySelector(`#${FORM_ID}`)
state.statusBox = modal.querySelector(`#${STATUS_ID}`)
state.submitBtn = modal.querySelector('button[type="submit"]')
state.cancelBtn = modal.querySelector('button[data-role="cancel"]')
modal.querySelector('.seedbox-modal__close').addEventListener('click', closeModal)
modal.addEventListener('click', evt => {
if (evt.target === modal) {
closeModal()
}
})
state.cancelBtn.addEventListener('click', closeModal)
state.form.addEventListener('submit', handleBatchSubmit)
}
function openModal() {
if (!state.modal) {
return
}
state.form.reset()
state.statusBox.style.display = 'none'
state.statusBox.innerHTML = ''
state.modal.classList.add('is-visible')
const firstInput = state.form.querySelector('input[name="operator"]')
if (firstInput) {
firstInput.focus()
}
}
function closeModal() {
if (state.modal) {
state.modal.classList.remove('is-visible')
}
}
async function handleBatchSubmit(event) {
event.preventDefault()
if (!state.form) {
return
}
const operator = state.form.operator.value.trim()
const bandwidth = state.form.bandwidth.value.trim()
const comment = state.form.comment.value.trim()
const rawEntries = state.form.entries.value.trim()
if (!rawEntries) {
renderStatus([{type: 'error', text: '请至少填写一行 IP 数据。'}])
return
}
const {validEntries, skippedLines} = parseEntries(rawEntries)
if (!validEntries.length) {
renderStatus([{type: 'error', text: '没有检测到有效的 IPv4/IPv6 地址。'}])
return
}
toggleFormDisabled(true)
renderStatus([{type: 'info', text: `准备登记 ${validEntries.length} 条记录...`}])
const results = []
for (const entry of validEntries) {
updateLiveStatus(entry)
try {
await submitSeedBox({operator, bandwidth, comment, ip: entry.ip})
results.push({type: 'success', text: `${entry.family} ${entry.ip} - 成功`})
} catch (err) {
console.warn('[SeedBoxBatch] 提交失败', entry, err)
results.push({type: 'error', text: `${entry.family} ${entry.ip} - ${err.message || '提交失败'}`})
}
}
skippedLines.forEach(item => {
results.push({type: 'skip', text: `第 ${item.line} 行已跳过:${item.value}`})
})
renderStatus(results)
toggleFormDisabled(false)
const hasError = results.some(item => item.type === 'error')
const successCount = results.filter(item => item.type === 'success').length
const shouldReload = document.getElementById(AUTO_RELOAD_ID)?.checked
if (!hasError && successCount === validEntries.length && shouldReload) {
results.push({type: 'info', text: '全部成功,3 秒后刷新页面。'})
renderStatus(results)
setTimeout(() => window.location.reload(), 3000)
}
}
function toggleFormDisabled(disabled) {
state.submitBtn.disabled = disabled
state.cancelBtn.disabled = disabled
const closeBtn = state.modal.querySelector('.seedbox-modal__close')
closeBtn.disabled = disabled
state.submitBtn.textContent = disabled ? '处理中...' : '开始登记'
}
function updateLiveStatus(entry) {
state.statusBox.style.display = 'block'
state.statusBox.innerHTML = `正在登记:${entry.family} ${entry.ip}
`
}
async function submitSeedBox({operator, bandwidth, comment, ip}) {
const payload = new URLSearchParams()
payload.append('action', 'addSeedBoxRecord')
payload.append('params[operator]', operator)
payload.append('params[bandwidth]', bandwidth)
payload.append('params[ip_begin]', '')
payload.append('params[ip_end]', '')
payload.append('params[ip]', ip)
payload.append('params[comment]', comment)
const response = await fetch(ACTION_URL, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
},
body: payload.toString(),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const data = await response.json()
if (data.ret !== 0) {
throw new Error(data.msg || '接口返回错误')
}
return data
}
function parseEntries(text) {
const lines = text.split(/\r?\n/)
const validEntries = []
const skippedLines = []
lines.forEach((line, index) => {
const trimmed = line.trim()
if (!trimmed) {
return
}
const slashIndex = trimmed.indexOf('/')
const hasSlash = slashIndex > -1
const firstPart = hasSlash ? trimmed.slice(0, slashIndex).trim() : trimmed
const secondPart = hasSlash ? trimmed.slice(slashIndex + 1).trim() : ''
let found = false
if (hasSlash) {
if (firstPart && isValidIPv4(firstPart)) {
validEntries.push({ip: firstPart, family: 'IPv4', line: index + 1})
found = true
}
if (secondPart && isValidIPv6(secondPart)) {
validEntries.push({ip: secondPart, family: 'IPv6', line: index + 1})
found = true
}
} else {
if (isValidIPv4(firstPart)) {
validEntries.push({ip: firstPart, family: 'IPv4', line: index + 1})
found = true
} else if (isValidIPv6(firstPart)) {
validEntries.push({ip: firstPart, family: 'IPv6', line: index + 1})
found = true
}
}
if (!found) {
skippedLines.push({line: index + 1, value: trimmed})
}
})
return {validEntries, skippedLines}
}
function isValidIPv4(ip) {
const ipv4Regex = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/
return ipv4Regex.test(ip)
}
function isValidIPv6(ip) {
if (!ip || ip.length > 39 || !/^[0-9a-fA-F:]+$/.test(ip)) {
return false
}
if (ip.includes(':::')) {
return false
}
const parts = ip.split('::')
if (parts.length > 2) {
return false
}
const hextetValid = segment => segment.length > 0 && segment.length <= 4 && /^[0-9a-fA-F]+$/.test(segment)
const listSegments = segment => (segment ? segment.split(':').filter(Boolean) : [])
const left = listSegments(parts[0])
const right = listSegments(parts[1])
if (!left.every(hextetValid) || !right.every(hextetValid)) {
return false
}
if (parts.length === 1) {
return left.length === 8
}
return left.length + right.length < 8
}
function renderStatus(items) {
state.statusBox.style.display = 'block'
const listItems = items
.map(item => `${escapeHtml(item.text)}`)
.join('')
state.statusBox.innerHTML = ``
}
function escapeHtml(text) {
return text
.replace(/&/g, '&')
.replace(//g, '>')
}
})()