Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# 1. New API 渠道迁移脚本(Tampermonkey)

这是一个油猴脚本,用来在 `*/console/channel*` 页面:
这是一个油猴脚本,用来在 new-api 的渠道页面:

- 旧版前端:`*/console/channel*`
- 新版前端:`*/channels*`、`*/_authenticated/channels*`

- **导出**:把渠道列表和配置导出成一个 `JSON` 文件
- **导入**:把这个 `JSON` 文件导入到另一个实例里(用于迁移)
Expand All @@ -11,7 +14,7 @@

我这边验证过的环境是:

- new-api:`v0.11.5`
- new-api:`v0.11.5`、`v1.0.0-rc.14`
- 模式:自用模式

如果你的版本/模式不一样:
Expand Down Expand Up @@ -56,7 +59,9 @@

打开你的实例里的渠道页面:

- `https://你的域名/console/channel`
- 旧版前端:`https://你的域名/console/channel`
- 新版前端:`https://你的域名/channels`
- 某些新版路由也可能是:`https://你的域名/_authenticated/channels`

页面右侧会出现一个小黑面板,能看到版本号(比如 `v0.3.4`)。

Expand Down Expand Up @@ -89,7 +94,7 @@
### 4.4 导入 JSON(迁移到另一个实例)

1. 先在目标实例登录(要有创建渠道权限)
2. 打开目标实例的:`/console/channel`
2. 打开目标实例的渠道页:`/console/channel` 或 `/channels`
3. 点右侧面板:`导入 JSON`
4. 选择你之前导出的 `channels_*.json`
5. 勾选你要导入的渠道
Expand Down Expand Up @@ -130,3 +135,4 @@

- key 属于敏感信息。导出含 key 的 JSON 文件,请你自己保管好。
- 不要把 2FA 的 secret 给任何脚本/任何人(很危险)。

78 changes: 71 additions & 7 deletions new-api-channel-migrator.user.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// ==UserScript==
// @name New API 渠道迁移(导出/导入)
// @namespace https://github.com/
// @version 0.1.0
// @description 在 /console/channel 页面导出/导入渠道配置(支持可选导出 key,可能需要 2FA/安全验证
// @version 0.4.0
// @description 在 new-api 渠道页导出/导入渠道配置(兼容旧版 /console/channel 和新版 /channels
// @match *://*/console/channel*
// @match *://*/channels*
// @match *://*/_authenticated/channels*
// @run-at document-start
// @grant GM_addStyle
// @grant GM_registerMenuCommand
Expand All @@ -14,7 +16,7 @@

const APP = Object.freeze({
NAME: 'New API 渠道迁移',
VERSION: '0.3.4',
VERSION: '0.4.0',
});

const UI = Object.freeze({
Expand Down Expand Up @@ -104,8 +106,12 @@
// Small in-memory cache to avoid repeated key calls.
keyCacheByChannelId: new Map(),
uiReady: false,
styleReady: false,
routeHooksInstalled: false,
};

const CHANNEL_PAGE_PREFIXES = Object.freeze(['/console/channel', '/channels', '/_authenticated/channels']);

// ----------------------------
// Small helpers
// ----------------------------
Expand All @@ -130,6 +136,21 @@
return Math.max(min, Math.min(max, n));
}

function normalizePathname(pathname) {
const path = String(pathname || '/').trim() || '/';
if (path.length > 1 && path.endsWith('/')) return path.slice(0, -1);
return path;
}

function isChannelPagePath(pathname) {
const path = normalizePathname(pathname);
return CHANNEL_PAGE_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
}

function isChannelPage() {
return isChannelPagePath(location.pathname);
}

function makeDraggable(targetEl, handleEl) {
if (!targetEl || !handleEl) return;
try {
Expand Down Expand Up @@ -874,6 +895,8 @@
// ----------------------------

function ensureStyle() {
if (state.styleReady) return;
state.styleReady = true;
const css = `
#${UI.PANEL_ID} {
position: fixed;
Expand Down Expand Up @@ -1026,6 +1049,12 @@
if (el) el.remove();
}

function removePanel() {
const el = document.getElementById(UI.PANEL_ID);
if (el) el.remove();
state.uiReady = false;
}

function showModal(title, bodyNode, actions) {
closeModal();
const header = h('div', { class: 'header' }, [
Expand Down Expand Up @@ -1053,7 +1082,7 @@
const panel = h('div', { id: UI.PANEL_ID }, []);
const header = h('div', { class: 'panel-header' }, [
h('div', { class: 'panel-title' }, APP.NAME),
h('button', { class: 'panel-close', title: 'Close', onclick: () => panel.remove() }, '×'),
h('button', { class: 'panel-close', title: 'Close', onclick: removePanel }, '×'),
]);

panel.appendChild(header);
Expand Down Expand Up @@ -1101,10 +1130,13 @@
function openHelpModal() {
const lines = [
`当前站点:${location.origin}`,
`当前路径:${location.pathname}`,
`渠道页识别:${isChannelPage() ? '是' : '否'}`,
'学习接口:已关闭(只走固定 API)',
`鉴权头:Authorization ${state.authHeaders.Authorization ? '有' : '无'},New-API-User ${state.authHeaders['New-API-User'] ? '有' : '无'}`,
'',
'提示:',
'0)当前脚本兼容旧版 /console/channel 和新版 /channels、/_authenticated/channels',
'1)导出“含 key”会做一次“安全验证”(可能要 2FA 验证码)',
' - 验证码只会缓存到它过期(一般 30 秒)',
' - 安全验证会在服务端记一会儿(看接口返回的 expires_at)',
Expand Down Expand Up @@ -1493,6 +1525,38 @@
}
}

function syncPanelVisibility() {
if (isChannelPage()) ensurePanel();
else {
closeModal();
removePanel();
}
}

function installRouteHooks() {
if (state.routeHooksInstalled) return;
state.routeHooksInstalled = true;

const notifyRouteChange = () => {
window.setTimeout(syncPanelVisibility, 0);
};

const wrapHistoryMethod = (methodName) => {
const original = history[methodName];
if (typeof original !== 'function') return;
history[methodName] = function (...args) {
const result = original.apply(this, args);
notifyRouteChange();
return result;
};
};

wrapHistoryMethod('pushState');
wrapHistoryMethod('replaceState');
window.addEventListener('popstate', notifyRouteChange);
window.addEventListener('hashchange', notifyRouteChange);
}

// ----------------------------
// Bootstrap UI at DOM ready
// ----------------------------
Expand All @@ -1504,8 +1568,8 @@
else document.addEventListener('DOMContentLoaded', fn, { once: true });
}
onReady(() => {
// Only show UI on console/channel page (avoid match edge cases).
if (!location.pathname.startsWith('/console/channel')) return;
ensurePanel();
installRouteHooks();
syncPanelVisibility();
});
})();