- 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: 使用说明与下载原理分析
230 lines
9.1 KiB
PowerShell
230 lines
9.1 KiB
PowerShell
# 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
|
||
}
|
||
}
|