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:
+229
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user