feat: 引入 so-novel submodule 并提供非交互下载工具

- vendor/so-novel: 上游 v1.12.0 (规则驱动的书源抓取/下载引擎)
- tools/src: ToolMain 封装 search/download/toc/sources,stdout 只输出 JSON
- tools/build.ps1: 自动定位/下载 JDK21,按平台显式激活 javet 原生库 profile
- bin/novel.ps1|.cmd: 命令行入口(人读输出或 -Json)
- SKILL.md/README.md: 使用说明与下载原理分析
This commit is contained in:
cheney
2026-11-04 15:12:10 +08:00
commit 221b1f8add
11 changed files with 1372 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
@echo off
rem ebook-skill :: so-novel command line shim for cmd.exe
setlocal
where pwsh >nul 2>nul
if %ERRORLEVEL%==0 (
pwsh -NoProfile -ExecutionPolicy Bypass -File "%~dp0novel.ps1" %*
) else (
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0novel.ps1" %*
)
exit /b %ERRORLEVEL%
+229
View File
@@ -0,0 +1,229 @@
# ebook-skill :: so-novel command line
#
# novel search <keyword> [-Limit N] [-Source ID] [-Timeout SEC] [-Log] [-Json]
# novel download <detail-url> [-Ext txt|epub|html|pdf] [-Out DIR] [-Log] [-Json]
# novel toc <detail-url> [-Json]
# novel sources [-Json]
#
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot '..\tools\lib.ps1')
$paths = Get-ToolPaths
function Show-Help {
@"
ebook-skill / so-novel
novel search <关键词> [-Limit N] [-Source ID] [-Timeout SEC] [-Log] [-Json]
novel download <详情页链接> [-Ext txt|epub|html|pdf] [-Out 目录] [-Log] [-Json]
novel toc <详情页链接> [-Json] 只看目录,不下载
novel sources [-Json] 列出内置书源
说明
-Json 输出原始 JSON(默认输出人读表格)
-Log 附带 so-novel 运行日志尾部,便于排查源站问题
-Limit 每个书源最多取多少条搜索结果(默认 30)
下载落盘默认在 <仓库>\books,文件名形如 书名(作者).txt
"@ | Write-Host
}
function Ensure-Built {
if ((Test-Path $paths.AppJar) -and (Test-Path $paths.Classes)) { return }
Write-Host '[setup] first run: building so-novel engine + wrapper ...' -ForegroundColor Yellow
& (Join-Path $paths.Tools 'build.ps1')
if ($LASTEXITCODE -ne 0) { throw "build failed (exit $LASTEXITCODE)" }
}
function Invoke-Tool {
param([string[]]$JavaArgs)
Ensure-Built
$java = Get-JavaCommand
$cp = $paths.AppJar + ';' + $paths.Classes
Push-Location $paths.Runtime
try {
$raw = (& $java '-Dfile.encoding=UTF-8' "-Dconfig.file=$($paths.Config)" '-cp' $cp 'skills.novel.ToolMain' @JavaArgs 2>&1 | Out-String)
$code = $LASTEXITCODE
} finally {
Pop-Location
}
return [pscustomobject]@{ Raw = $raw.Trim(); Code = $code }
}
function Get-Option {
param([string[]]$Tokens, [string]$Name, $Default = $null)
for ($i = 0; $i -lt $Tokens.Count; $i++) {
$t = $Tokens[$i]
if ($t -ieq $Name -and ($i + 1) -lt $Tokens.Count) { return $Tokens[$i + 1] }
if ($t -like "$Name=*") { return $t.Substring($Name.Length + 1) }
}
return $Default
}
function Test-Flag {
param([string[]]$Tokens, [string]$Name)
foreach ($t in $Tokens) { if ($t -ieq $Name) { return $true } }
return $false
}
function Get-Positionals {
param([string[]]$Tokens, [string[]]$ValueOptions)
$values = @()
for ($i = 0; $i -lt $Tokens.Count; $i++) {
$t = $Tokens[$i]
if ($t.StartsWith('-')) {
if ($ValueOptions -contains $t -and ($i + 1) -lt $Tokens.Count) { $i++ }
continue
}
$values += $t
}
return $values
}
function Show-SearchResult {
param($Result)
if (-not $Result.ok) {
Write-Host ("搜索失败: " + $Result.error) -ForegroundColor Red
return
}
$failedCount = 0
if ($Result.sources.failed) { $failedCount = @($Result.sources.failed).Count }
Write-Host ("关键词「{0}」:命中 {1} 条,书源 {2} 个(空 {3},异常 {4}),耗时 {5} ms" -f `
$Result.keyword, $Result.count, $Result.sources.total, $Result.sources.empty, $failedCount, $Result.tookMs) -ForegroundColor Cyan
if ($Result.count -eq 0) {
Write-Host '没有命中。试试更完整的书名/作者,或 -Limit 调大。' -ForegroundColor Yellow
return
}
$rows = @()
foreach ($r in $Result.results) {
$latest = [string]$r.latestChapter
if ($latest.Length -gt 24) { $latest = $latest.Substring(0, 24) + '...' }
$author = [string]$r.author
if (-not $author) { $author = '?' }
Write-Host ("[{0}] {1} —— {2}" -f $r.index, $r.bookName, $author) -ForegroundColor White
Write-Host (" 最新章节: {0} 更新: {1} 书源: {2}" -f $latest, $r.lastUpdateTime, $r.sourceName) -ForegroundColor DarkGray
Write-Host (" {0}" -f $r.url) -ForegroundColor DarkCyan
}
Write-Host '下载示例: novel download <链接> -Ext txt' -ForegroundColor DarkGray
}
function Show-DownloadResult {
param($Result)
if (-not $Result.ok) {
Write-Host ("下载失败: " + $Result.error) -ForegroundColor Red
if ($Result.bookName) { Write-Host (" 书籍: {0} / {1}" -f $Result.bookName, $Result.author) }
return
}
Write-Host ("完成《{0}》({1}) {2} 章 {3} s" -f $Result.bookName, $Result.author, $Result.chapters, $Result.seconds) -ForegroundColor Green
Write-Host (" 文件: {0} ({1:N0} KB)" -f $Result.file, ($Result.bytes / 1KB))
Write-Host (" 书源: #{0} {1}" -f $Result.sourceId, $Result.sourceName) -ForegroundColor DarkGray
}
function Show-TocResult {
param($Result)
if (-not $Result.ok) {
Write-Host ("目录解析失败: " + $Result.error) -ForegroundColor Red
return
}
Write-Host ("《{0}》 书源 #{1} {2}" -f $Result.url, $Result.sourceId, $Result.sourceName) -ForegroundColor Cyan
Write-Host (" 章节数: {0}" -f $Result.chapters)
Write-Host (" 开头: " + (@($Result.firstChapters) -join ' / '))
Write-Host (" 结尾: " + (@($Result.lastChapters) -join ' / '))
}
function Show-Sources {
param($Result)
Write-Host ("激活规则: {0} 共 {1} 个书源" -f $Result.activeRules, $Result.count) -ForegroundColor Cyan
foreach ($s in $Result.sources) {
$searchable = '-'
if ($s.searchable) { $searchable = '支持搜索' }
Write-Host ("{0,2}. {1,-14} {2,-8} {3}" -f $s.id, $s.name, $searchable, $s.url)
}
}
# ---------------------------------------------------------------- dispatch
function Invoke-SearchCommand {
param([string[]]$Tokens)
$valueOptions = @('-Limit', '-Source', '-Timeout')
$keyword = (@(Get-Positionals -Tokens $Tokens -ValueOptions $valueOptions) -join ' ').Trim()
if (-not $keyword) { Write-Host '用法: novel search <关键词>' -ForegroundColor Yellow; exit 2 }
$javaArgs = @('search', $keyword,
'--limit', [string](Get-Option $Tokens '-Limit' 30),
'--source', [string](Get-Option $Tokens '-Source' '0'),
'--timeout', [string](Get-Option $Tokens '-Timeout' '180'))
if (Test-Flag $Tokens '-Log') { $javaArgs += '--log' }
$result = Invoke-Tool -JavaArgs $javaArgs
if (Test-Flag $Tokens '-Json') { $result.Raw | Write-Output }
else { Show-SearchResult ($result.Raw | ConvertFrom-Json) }
exit $result.Code
}
function Invoke-DownloadCommand {
param([string[]]$Tokens)
$valueOptions = @('-Ext', '-Out', '-Url')
$url = Get-Option $Tokens '-Url' $null
if (-not $url) {
$positionals = @(Get-Positionals -Tokens $Tokens -ValueOptions $valueOptions)
if ($positionals.Count -gt 0) { $url = $positionals[0] }
}
if (-not $url) { Write-Host '用法: novel download <详情页链接> [-Ext txt] [-Out 目录]' -ForegroundColor Yellow; exit 2 }
$out = Get-Option $Tokens '-Out' $null
if ($out) { $out = (New-Item -ItemType Directory -Force -Path $out).FullName }
$javaArgs = @('download', $url, '--ext', [string](Get-Option $Tokens '-Ext' 'txt'))
if ($out) { $javaArgs += @('--out', $out) }
if (Test-Flag $Tokens '-Log') { $javaArgs += '--log' }
$result = Invoke-Tool -JavaArgs $javaArgs
if (Test-Flag $Tokens '-Json') { $result.Raw | Write-Output }
else { Show-DownloadResult ($result.Raw | ConvertFrom-Json) }
exit $result.Code
}
function Invoke-TocCommand {
param([string[]]$Tokens)
$url = Get-Option $Tokens '-Url' $null
if (-not $url) {
$positionals = @(Get-Positionals -Tokens $Tokens -ValueOptions @('-Url'))
if ($positionals.Count -gt 0) { $url = $positionals[0] }
}
if (-not $url) { Write-Host '用法: novel toc <详情页链接>' -ForegroundColor Yellow; exit 2 }
$javaArgs = @('toc', $url)
if (Test-Flag $Tokens '-Log') { $javaArgs += '--log' }
$result = Invoke-Tool -JavaArgs $javaArgs
if (Test-Flag $Tokens '-Json') { $result.Raw | Write-Output }
else { Show-TocResult ($result.Raw | ConvertFrom-Json) }
exit $result.Code
}
$argv = @($args)
if ($argv.Count -eq 0) { Show-Help; exit 0 }
$command = ([string]$argv[0]).ToLowerInvariant()
$rest = @()
if ($argv.Count -gt 1) { $rest = $argv[1..($argv.Count - 1)] }
switch ($command) {
'search' { Invoke-SearchCommand -Tokens $rest }
'download' { Invoke-DownloadCommand -Tokens $rest }
'dl' { Invoke-DownloadCommand -Tokens $rest }
'toc' { Invoke-TocCommand -Tokens $rest }
'chapters' { Invoke-TocCommand -Tokens $rest }
'sources' {
$result = Invoke-Tool -JavaArgs @('sources')
if (Test-Flag $rest '-Json') { $result.Raw | Write-Output }
else { Show-Sources ($result.Raw | ConvertFrom-Json) }
exit $result.Code
}
'build' {
& (Join-Path $paths.Tools 'build.ps1') @rest
exit $LASTEXITCODE
}
default {
if ($command -in @('-h', '--help', 'help')) { Show-Help; exit 0 }
Write-Host "未知命令: $command" -ForegroundColor Red
Show-Help
exit 2
}
}