Merge pull request #1056 from EstrellaXD/feat/nexusphp-pt-provider

feat(webui): NexusPHP PT-site preset for search providers
This commit is contained in:
Estrella Pan
2026-07-07 10:45:52 +02:00
committed by GitHub
7 changed files with 406 additions and 19 deletions

View File

@@ -0,0 +1,63 @@
"""NexusPHP PT 站点(如 Audiences.me)torrentrss.php 输出的解析回归测试。
PT 支持依赖通用 RSS 解析路径:条目带 <enclosure>(.torrent 下载地址含
passkey)时取 enclosure 为下载链接、<link>(details.php)为主页;
linktype=dl 且无 enclosure 时 <link> 本身就是下载地址。
"""
import xml.etree.ElementTree as ET
from module.network.site import rss_parser
NEXUSPHP_RSS_WITH_ENCLOSURE = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Audiences :: torrents</title>
<link>https://audiences.me</link>
<item>
<title>[Sakurato] Kusuriya no Hitorigoto [21][AVC-8bit 1080p AAC][CHS]</title>
<link>https://audiences.me/details.php?id=12345</link>
<enclosure url="https://audiences.me/download.php?id=12345&amp;passkey=abc123" type="application/x-bittorrent" length="694157244"/>
<guid>https://audiences.me/details.php?id=12345</guid>
<pubDate>Sat, 04 Jul 2026 12:00:00 +0800</pubDate>
</item>
</channel>
</rss>"""
NEXUSPHP_RSS_LINKTYPE_DL = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Audiences :: torrents</title>
<link>https://audiences.me</link>
<item>
<title>[ANi] Boku no Hero Academia S07 - 08 [1080P][WEB-DL][AAC AVC][CHT]</title>
<link>https://audiences.me/download.php?id=67890&amp;passkey=abc123</link>
<guid>https://audiences.me/details.php?id=67890</guid>
<pubDate>Sat, 04 Jul 2026 12:00:00 +0800</pubDate>
</item>
</channel>
</rss>"""
def test_rss_parser_nexusphp_enclosure_yields_download_url() -> None:
soup = ET.fromstring(NEXUSPHP_RSS_WITH_ENCLOSURE)
results = rss_parser(soup)
assert results == [
(
"[Sakurato] Kusuriya no Hitorigoto [21][AVC-8bit 1080p AAC][CHS]",
"https://audiences.me/download.php?id=12345&passkey=abc123",
"https://audiences.me/details.php?id=12345",
)
]
def test_rss_parser_nexusphp_linktype_dl_uses_link_as_download_url() -> None:
soup = ET.fromstring(NEXUSPHP_RSS_LINKTYPE_DL)
results = rss_parser(soup)
assert results == [
(
"[ANi] Boku no Hero Academia S07 - 08 [1080P][WEB-DL][AAC AVC][CHT]",
"https://audiences.me/download.php?id=67890&passkey=abc123",
"",
)
]

View File

@@ -1,6 +1,10 @@
<script lang="ts" setup>
import { Delete, EditTwo, Plus } from '@icon-park/vue-next';
import { useConfirm } from '@/hooks/useConfirm';
import {
buildNexusPhpSearchUrl,
isValidNexusPhpCategoryIds,
} from '@/utils/nexusphp';
interface SearchProvider {
name: string;
@@ -33,6 +37,53 @@ const editingIndex = ref<number>(-1);
const formName = ref('');
const formUrl = ref('');
// Add-dialog mode: custom URL template, or NexusPHP PT-site preset that
// builds a torrentrss.php search template from base URL + passkey.
type AddMode = 'custom' | 'nexusphp';
const formMode = ref<AddMode>('custom');
const formBaseUrl = ref('');
const formPasskey = ref('');
const formCategoryId = ref('');
const addModeOptions = computed(() => [
{ label: t('config.search_provider_set.mode_custom'), value: 'custom' },
{ label: t('config.search_provider_set.mode_nexusphp'), value: 'nexusphp' },
]);
const categoryIdsValid = computed(() =>
isValidNexusPhpCategoryIds(formCategoryId.value)
);
const builtNexusPhpUrl = computed(() => {
if (!formBaseUrl.value.trim() || !formPasskey.value.trim()) return '';
if (!categoryIdsValid.value) return '';
return buildNexusPhpSearchUrl({
baseUrl: formBaseUrl.value,
passkey: formPasskey.value,
categoryId: formCategoryId.value,
});
});
// 预览里遮住 passkey(存储的 URL 不变——供应商列表本就显示完整 URL)
const previewNexusPhpUrl = computed(() =>
builtNexusPhpUrl.value.replace(
/(passkey=)([^&]+)/,
(_, prefix: string, key: string) =>
`${prefix}${key.length > 4 ? `${key.slice(0, 4)}…` : '…'}`
)
);
const addFormUrl = computed(() =>
formMode.value === 'nexusphp' ? builtNexusPhpUrl.value : formUrl.value.trim()
);
const canAdd = computed(
() =>
!!formName.value.trim() &&
!!addFormUrl.value &&
validateUrl(addFormUrl.value)
);
// Default providers that cannot be deleted
const defaultProviderNames = ['mikan', 'nyaa', 'dmhy'];
@@ -76,6 +127,10 @@ async function saveProviders() {
function openAddDialog() {
formName.value = '';
formUrl.value = '';
formMode.value = 'custom';
formBaseUrl.value = '';
formPasskey.value = '';
formCategoryId.value = '';
showAddDialog.value = true;
}
@@ -88,7 +143,7 @@ function openEditDialog(provider: SearchProvider, index: number) {
}
async function handleAdd() {
if (!formName.value.trim() || !formUrl.value.trim()) return;
if (!canAdd.value) return;
// Check for duplicate name
if (providers.value.some((p) => p.name === formName.value.trim())) {
@@ -97,13 +152,16 @@ async function handleAdd() {
providers.value.push({
name: formName.value.trim(),
url: formUrl.value.trim(),
url: addFormUrl.value,
});
await saveProviders();
showAddDialog.value = false;
formName.value = '';
formUrl.value = '';
formBaseUrl.value = '';
formPasskey.value = '';
formCategoryId.value = '';
}
async function handleEdit() {
@@ -226,26 +284,90 @@ function validateUrl(url: string): boolean {
:title="$t('config.search_provider_set.add_title')"
>
<div space-y-16>
<ab-segmented
v-model:value="formMode"
size="sm"
:options="addModeOptions"
:aria-label="$t('config.search_provider_set.add_title')"
/>
<ab-field :label="$t('config.search_provider_set.name')">
<ab-input
v-model="formName"
:placeholder="$t('config.search_provider_set.name_placeholder')"
:placeholder="
formMode === 'nexusphp'
? $t('config.search_provider_set.name_placeholder_nexusphp')
: $t('config.search_provider_set.name_placeholder')
"
:maxlength="32"
/>
</ab-field>
<ab-field :label="$t('config.search_provider_set.url')">
<ab-input
v-model="formUrl"
type="text"
:placeholder="$t('config.search_provider_set.url_placeholder')"
@keyup.enter="handleAdd"
/>
</ab-field>
<template v-if="formMode === 'custom'">
<ab-field :label="$t('config.search_provider_set.url')">
<ab-input
v-model="formUrl"
type="text"
:placeholder="$t('config.search_provider_set.url_placeholder')"
@keyup.enter="handleAdd"
/>
</ab-field>
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
{{ $t('config.search_provider_set.url_missing_placeholder') }}
</div>
<div
v-if="formUrl && !validateUrl(formUrl)"
class="validation-warning"
>
{{ $t('config.search_provider_set.url_missing_placeholder') }}
</div>
</template>
<template v-else>
<ab-field :label="$t('config.search_provider_set.base_url')">
<ab-input
v-model="formBaseUrl"
type="text"
:placeholder="
$t('config.search_provider_set.base_url_placeholder')
"
/>
</ab-field>
<ab-field :label="$t('config.search_provider_set.passkey')">
<ab-input
v-model="formPasskey"
type="text"
:placeholder="
$t('config.search_provider_set.passkey_placeholder')
"
/>
</ab-field>
<ab-field :label="$t('config.search_provider_set.category_id')">
<ab-input
v-model="formCategoryId"
type="text"
:placeholder="
$t('config.search_provider_set.category_id_placeholder')
"
@keyup.enter="handleAdd"
/>
</ab-field>
<div
v-if="formCategoryId && !categoryIdsValid"
class="validation-warning"
>
{{ $t('config.search_provider_set.category_id_invalid') }}
</div>
<div class="hint-text">
{{ $t('config.search_provider_set.nexusphp_hint') }}
</div>
<div v-if="previewNexusPhpUrl" class="url-preview">
{{ previewNexusPhpUrl }}
</div>
</template>
</div>
<template #footer>
@@ -255,9 +377,7 @@ function validateUrl(url: string): boolean {
<ab-button
size="sm"
variant="primary"
:disabled="
!formName.trim() || !formUrl.trim() || !validateUrl(formUrl)
"
:disabled="!canAdd"
@click="handleAdd"
>
{{ $t('config.apply') }}
@@ -381,6 +501,21 @@ function validateUrl(url: string): boolean {
line-height: 1.5;
}
.url-preview {
font-size: 12px;
color: var(--color-text-secondary);
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
monospace;
padding: 8px 12px;
background: var(--color-surface-elevated, #f9fafb);
border-radius: 6px;
word-break: break-all;
:root.dark & {
background: var(--color-surface-elevated, #1f2937);
}
}
.validation-warning {
font-size: 12px;
color: var(--color-danger, #ef4444);

View File

@@ -142,7 +142,18 @@
"delete_confirm": "Are you sure you want to delete this provider?",
"load_failed": "Failed to load search providers",
"save_failed": "Failed to save search providers",
"save_success": "Search providers saved"
"save_success": "Search providers saved",
"mode_custom": "Custom URL",
"mode_nexusphp": "PT Site (NexusPHP)",
"name_placeholder_nexusphp": "e.g., audiences",
"base_url": "Site URL",
"base_url_placeholder": "https://audiences.me",
"passkey": "Passkey",
"passkey_placeholder": "Passkey from the site's user control panel",
"category_id": "Category ID (optional)",
"category_id_placeholder": "e.g., 402",
"nexusphp_hint": "Builds a torrentrss.php search template from your passkey. Note: stock NexusPHP ignores the search parameter and always returns the latest torrents — verify on your site that RSS search actually filters results.",
"category_id_invalid": "Category IDs must be numeric (comma-separated for multiple)"
}
},
"downloader": {

View File

@@ -142,7 +142,18 @@
"delete_confirm": "确定删除此搜索源?",
"load_failed": "加载搜索源失败",
"save_failed": "保存搜索源失败",
"save_success": "搜索源已保存"
"save_success": "搜索源已保存",
"mode_custom": "自定义 URL",
"mode_nexusphp": "PT 站点(NexusPHP)",
"name_placeholder_nexusphp": "例如:audiences",
"base_url": "站点地址",
"base_url_placeholder": "https://audiences.me",
"passkey": "Passkey",
"passkey_placeholder": "站点用户控制面板中的 Passkey",
"category_id": "分类 ID(可选)",
"category_id_placeholder": "例如:402",
"nexusphp_hint": "根据 Passkey 生成 torrentrss.php 搜索模板。注意:原生 NexusPHP 已停用 search 参数、对任何搜索词都返回最新种子,请先在站点上确认 RSS 搜索确实按词过滤。",
"category_id_invalid": "分类 ID 必须是数字(多个用逗号分隔)"
}
},
"downloader": {

View File

@@ -0,0 +1,115 @@
/**
* NexusPHP PT 站点(如 Audiences.me)搜索源 URL 模板构造。
* 生成的模板交给后端 search_provider 使用:后端用 str.replace 把
* 字面量 "%s" 换成搜索词,因此 %s 必须原样保留、不能被编码。
*/
import { describe, expect, it } from 'vitest';
import {
buildNexusPhpSearchUrl,
isValidNexusPhpCategoryIds,
} from '../nexusphp';
describe('buildNexusPhpSearchUrl', () => {
it('should build a torrentrss URL template when given base URL and passkey', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me',
passkey: 'abc123',
});
expect(url).toBe(
'https://audiences.me/torrentrss.php?rows=50&linktype=dl&passkey=abc123&search=%s'
);
});
it('should keep the literal %s placeholder unencoded', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me',
passkey: 'abc123',
});
expect(url.endsWith('search=%s')).toBe(true);
});
it('should emit a per-category flag when a category ID is provided', () => {
// 原生 NexusPHP torrentrss.php 只识别 cat<ID>=1 形式的分类参数,
// 裸 cat=<ID> 会被静默忽略
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me',
passkey: 'abc123',
categoryId: '402',
});
expect(url).toContain('&cat402=1&search=%s');
expect(url).not.toContain('cat=402');
});
it('should emit one flag per comma-separated category ID', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me',
passkey: 'abc123',
categoryId: '402, 403',
});
expect(url).toContain('&cat402=1&cat403=1&search=%s');
});
it('should omit the cat parameter when the category ID is blank', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me',
passkey: 'abc123',
categoryId: ' ',
});
expect(url).not.toContain('cat');
});
it('should strip trailing slashes from the base URL', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me/',
passkey: 'abc123',
});
expect(url).toContain('audiences.me/torrentrss.php');
});
it('should prepend https scheme when the base URL has none', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'audiences.me',
passkey: 'abc123',
});
expect(url.startsWith('https://audiences.me/')).toBe(true);
});
it('should URL-encode reserved characters in the passkey', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me',
passkey: 'a&b=c',
});
expect(url).toContain('passkey=a%26b%3Dc');
});
it('should trim surrounding whitespace from inputs', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: ' https://audiences.me ',
passkey: ' abc123 ',
});
expect(url).toBe(
'https://audiences.me/torrentrss.php?rows=50&linktype=dl&passkey=abc123&search=%s'
);
});
});
describe('isValidNexusPhpCategoryIds', () => {
it('should accept an empty input as no filter', () => {
expect(isValidNexusPhpCategoryIds('')).toBe(true);
expect(isValidNexusPhpCategoryIds(' ')).toBe(true);
});
it('should accept a single numeric ID', () => {
expect(isValidNexusPhpCategoryIds('402')).toBe(true);
});
it('should accept comma-separated numeric IDs with spaces and a trailing comma', () => {
expect(isValidNexusPhpCategoryIds('402, 403,')).toBe(true);
});
it('should reject non-numeric IDs', () => {
expect(isValidNexusPhpCategoryIds('abc')).toBe(false);
expect(isValidNexusPhpCategoryIds('402,abc')).toBe(false);
expect(isValidNexusPhpCategoryIds('4 02')).toBe(false);
});
});

View File

@@ -0,0 +1,51 @@
/**
* NexusPHP PT 站点(如 Audiences.me)搜索源 URL 模板构造。
*
* NexusPHP 的 torrentrss.php 用 passkey 放在 URL 里鉴权,linktype=dl 让
* 条目链接指向 .torrent 下载地址,qBittorrent 可直接抓取。
*
* 注意:原生(新版)NexusPHP 已停用 torrentrss.php 的 search 参数
* (源码中写死 `$searchstr = null`),此时该模板对任何搜索词都返回
* 最新种子;只有保留了 search 支持的站点/分支能真正按词过滤。UI 的
* 提示文案要求用户自行在站点上验证。
*
* 分类参数必须用 cat<ID>=1 的逐分类开关形式(torrentrss.php 只识别
* /^(cat|sou|med|...)\d+$/ 形式的参数),裸 cat=<ID> 会被静默忽略。
*
* 返回的是后端 search_provider 的 URL 模板:字面量 "%s" 由后端替换为
* 搜索词,必须保持未编码。
*/
export interface NexusPhpProviderOptions {
baseUrl: string;
passkey: string;
/** NexusPHP 分类 ID(如 Audiences.me 的剧集/动漫为 402),可用逗号
* 分隔多个;留空则不过滤 */
categoryId?: string;
}
function parseCategoryIds(input: string | undefined): string[] {
return (input ?? '')
.split(',')
.map((s) => s.trim())
.filter(Boolean);
}
/** 分类 ID 输入校验:空 = 不过滤;否则每个逗号分隔段都必须是纯数字 */
export function isValidNexusPhpCategoryIds(input: string): boolean {
return parseCategoryIds(input).every((id) => /^\d+$/.test(id));
}
export function buildNexusPhpSearchUrl(
options: NexusPhpProviderOptions
): string {
let base = options.baseUrl.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(base)) {
base = `https://${base}`;
}
const passkey = encodeURIComponent(options.passkey.trim());
const catParams = parseCategoryIds(options.categoryId)
.map((id) => `&cat${encodeURIComponent(id)}=1`)
.join('');
return `${base}/torrentrss.php?rows=50&linktype=dl&passkey=${passkey}${catParams}&search=%s`;
}

View File

@@ -24,6 +24,7 @@ declare global {
const axios: typeof import('../../src/utils/axios')['axios']
const beforeAll: typeof import('vitest')['beforeAll']
const beforeEach: typeof import('vitest')['beforeEach']
const buildNexusPhpSearchUrl: typeof import('../../src/utils/nexusphp')['buildNexusPhpSearchUrl']
const chai: typeof import('vitest')['chai']
const computed: typeof import('vue')['computed']
const countLogLevels: typeof import('../../src/utils/log-parse')['countLogLevels']