用 PowerShell 把本地文本“自动投递”到 Typecho:auto-upload-watch.ps1(含完整代码)
本文最后由方少年更新于2025 年 11 月 10 日,已超过20天没有更新。如果文章内容或图片资源失效,请留言反馈,将会及时处理,谢谢!
用 PowerShell 把本地文本“自动投递”到 Typecho:auto-upload-watch.ps1(含完整代码)
这是一份可直接落地的自动化工具:把本地目录当“投稿箱”,脚本按时间顺序把文本文件批量发布到 Typecho,成功后自动归档、写日志、清理空目录。适合把日常笔记、抓取结果、迁移内容自动上架。
- 目录即分类:标题前缀使用相对路径的多级目录(用 | 连接),天然分组与检索更友好。
- 编码容错:优先 UTF-8,失败回退到 GBK(可改)。
- 端点自修复:自动尝试 /action/xmlrpc 与 /index.php/action/xmlrpc。
- 可一次性扫描,也可常驻轮询(默认 30 秒)。
下文先给出完整代码,再补简要使用说明与建议。
完整代码
<#
auto-upload-watch.ps1
说明:
- 双击运行后,递归扫描 D:\website-files\upload_file 下的待上传文本文件,按创建时间依次发布到 Typecho。
- 标题会加上相对上传根目录的路径前缀(多层用 | 连接),例如:c8-20251031_002439|064_caesar_full_flag
- 可选:正文顶部会附加来源路径提示(可在 Publish-OneFile 中关闭)。
- 发布成功的文件会移动到 D:\website-files\upload_completed\yyyy-MM-dd。
- 支持一次性扫描(RunOnce=$true)或持续轮询(RunOnce=$false,默认每30秒)。
#>
# ========== 内置配置(按需修改) ==========
$Global:TypechoConfig = [pscustomobject]@{
Endpoint = "https://www.fangshaonian.cn/action/xmlrpc" # 你的 XML-RPC 端点(若需 token,拼到 URL)
User = "admin" # API 用户名
Pass = "h3Ey&M3R^JnuJVFu" # API 密码(明文存放,注意文件权限)
BlogId = "1" # 通常为 "1"
DefaultCategories = @("博客") # 默认分类
DefaultTags = @("AutoUpload","Typecho") # 默认标签
PublishNow = $true # $true 直接发布;$false 草稿
TitleFromFilename = $true # 标题取文件名(不含扩展名)
FallbackEncoding = [Text.Encoding]::GetEncoding(936) # 备用读取编码(936=GBK)
MaxContentLength = 0 # 0=不截断;>0 截断内容长度
AllowedExtensions = @(".txt",".md",".markdown",".html",".htm",".log",".rst",".adoc",".ini",".cfg",".csv",".json",".xml",".yaml",".yml")
}
# 扫描与归档目录
$Global:UploadDir = "D:\website-files\upload_file"
$Global:CompletedDir = "D:\website-files\upload_completed"
$Global:LogFile = "D:\website-files\auto-upload.log" # 运行日志
$Global:RunOnce = $false # $true=仅扫一次并退出;$false=循环轮询
$Global:PollInterval = 30 # 轮询秒数
# ========================================
# ---------- 日志 ----------
function Write-Log {
param([string]$Level="INFO",[string]$Message)
$line = ("[{0:yyyy-MM-dd HH:mm:ss}] [{1}] {2}" -f (Get-Date), $Level.ToUpper(), $Message)
Write-Host $line
try { Add-Content -LiteralPath $Global:LogFile -Value $line } catch {}
}
# ---------- XML-RPC 工具 ----------
function XmlEscape([string]$s) {
if ($null -eq $s) { return "" }
[System.Security.SecurityElement]::Escape($s)
}
function Resolve-Endpoint([string]$ep,[switch]$useIndexPhp) {
if ($useIndexPhp -and $ep -match "/action/xmlrpc($|[?])" -and $ep -notmatch "/index\.php/") {
return ($ep -replace "/action/xmlrpc","/index.php/action/xmlrpc")
}
return $ep
}
function Invoke-XmlRpc {
param(
[Parameter(Mandatory)][string]$Endpoint,
[Parameter(Mandatory)][string]$XmlBody
)
try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {}
$bytes = [Text.Encoding]::UTF8.GetBytes($XmlBody)
(Invoke-WebRequest -Uri $Endpoint -Method Post -ContentType "text/xml; charset=utf-8" `
-Headers @{ "User-Agent" = "TypechoAutoUploadWatch/2025.11" } -Body $bytes -MaximumRedirection 5 -TimeoutSec 30).Content
}
function Parse-Fault([string]$raw) {
try { [xml]$doc = $raw } catch { return $null }
if (-not $doc.methodResponse.fault) { return $null }
$members = $doc.methodResponse.fault.value.struct.member
$code = ($members | Where-Object {$_.name -eq 'faultCode'}).value.int
$msg = ($members | Where-Object {$_.name -eq 'faultString'}).value.string
[pscustomobject]@{ Code = [int]$code; Message = [string]$msg }
}
function Test-TypechoXmlRpc {
param(
[Parameter(Mandatory)][string]$Endpoint,
[Parameter(Mandatory)][string]$User,
[Parameter(Mandatory)][string]$Pass,
[string]$BlogId="1",
[int]$Recent=1
)
$u = XmlEscape $User; $p = XmlEscape $Pass; $b = XmlEscape $BlogId
$xml = @"
<?xml version="1.0"?>
<methodCall>
<methodName>metaWeblog.getRecentPosts</methodName>
<params>
<param><value><string>$b</string></value></param>
<param><value><string>$u</string></value></param>
<param><value><string>$p</string></value></param>
<param><value><int>$Recent</int></value></param>
</params>
</methodCall>
"@
$raw = Invoke-XmlRpc -Endpoint $Endpoint -XmlBody $xml
if ($raw -match "<html" -or $raw -match "<!DOCTYPE html") {
return @{ Ok = $false; Reason = "收到 HTML(可能 CDN/防火墙拦截)"; Raw = $raw }
}
$fault = Parse-Fault $raw
if ($fault) { return @{ Ok = $false; Reason = "Fault $($fault.Code) - $($fault.Message)"; Raw = $raw } }
@{ Ok = $true; Raw = $raw }
}
function Publish-TypechoPost {
param(
[Parameter(Mandatory)][string]$Endpoint,
[Parameter(Mandatory)][string]$User,
[Parameter(Mandatory)][string]$Pass,
[Parameter(Mandatory)][string]$Title,
[Parameter(Mandatory)][string]$Content,
[string[]]$Categories = @(),
[string[]]$Tags = @(),
[string]$BlogId = "1",
[bool]$Publish = $false
)
$u = XmlEscape $User; $p = XmlEscape $Pass
$t = XmlEscape $Title; $c = XmlEscape $Content
$catsXml = ($Categories | ForEach-Object { "<value><string>$($_)</string></value>" }) -join ''
$tagsCsv = [string]::Join(", ", $Tags)
$pub = if ($Publish) { "1" } else { "0" }
$xml = @"
<?xml version="1.0"?>
<methodCall>
<methodName>metaWeblog.newPost</methodName>
<params>
<param><value><string>$BlogId</string></value></param>
<param><value><string>$u</string></value></param>
<param><value><string>$p</string></value></param>
<param>
<value>
<struct>
<member><name>title</name><value><string>$t</string></value></member>
<member><name>description</name><value><string>$c</string></value></member>
<member><name>categories</name><value><array><data>$catsXml</data></array></value></member>
<member><name>mt_keywords</name><value><string>$tagsCsv</string></value></member>
</struct>
</value>
</param>
<param><value><boolean>$pub</boolean></value></param>
</params>
</methodCall>
"@
$raw = Invoke-XmlRpc -Endpoint $Endpoint -XmlBody $xml
if ($fault = Parse-Fault $raw) { throw "发布失败:Fault $($fault.Code) - $($fault.Message)" }
if ($raw -match '<(?:i4|int)>(\d+)</(?:i4|int)>') { return [int]$matches[1] }
elseif ($raw -match '<string>([^<]+)</string>') { return $matches[1] }
else { return $raw }
}
# ---------- 文件读取与标题 ----------
function Read-TextFileSmart {
param([Parameter(Mandatory)][string]$Path)
try {
return [System.IO.File]::ReadAllText($Path, [Text.Encoding]::UTF8)
} catch {
try {
return [System.IO.File]::ReadAllText($Path, $Global:TypechoConfig.FallbackEncoding)
} catch {
try { return Get-Content -LiteralPath $Path -Raw -ErrorAction Stop }
catch { throw "读取失败:$($_.Exception.Message)" }
}
}
}
function Make-RelPathPrefix {
param(
[Parameter(Mandatory)][string]$Root, # 例如 D:\website-files\upload_file
[Parameter(Mandatory)][string]$Full # 文件完整路径
)
try {
$rootFull = [IO.Path]::GetFullPath($Root).TrimEnd('\','/')
$fileFull = [IO.Path]::GetFullPath($Full)
} catch {
return ""
}
if ($fileFull.StartsWith($rootFull, [StringComparison]::OrdinalIgnoreCase)) {
$rel = $fileFull.Substring($rootFull.Length).TrimStart('\','/')
$dir = [IO.Path]::GetDirectoryName($rel)
if ([string]::IsNullOrWhiteSpace($dir)) { return "" }
# 将目录层级用 | 连接
$parts = $dir -split '[\\/]' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
if ($parts.Count -gt 0) { return ($parts -join '|') } else { return "" }
} else {
return ""
}
}
function Make-TitleFromPath {
param([Parameter(Mandatory)][string]$Path)
$name = [System.IO.Path]::GetFileNameWithoutExtension($Path)
if ([string]::IsNullOrWhiteSpace($name)) { $name = "未命名" }
$prefix = Make-RelPathPrefix -Root $Global:UploadDir -Full $Path
if ([string]::IsNullOrWhiteSpace($prefix)) { return $name }
else { return "$prefix|$name" }
}
# ---------- 业务:扫描、发布、移动 ----------
function Ensure-Directories {
foreach($d in @($Global:UploadDir,$Global:CompletedDir,(Split-Path -Parent $Global:LogFile))) {
if (-not [string]::IsNullOrWhiteSpace($d) -and -not (Test-Path -LiteralPath $d)) {
New-Item -ItemType Directory -Path $d -Force | Out-Null
Write-Log "INFO" "已创建目录:$d"
}
}
}
function Pick-PendingFiles {
# 递归匹配允许的扩展名,过滤隐藏/系统文件,按创建时间排序
$exts = $Global:TypechoConfig.AllowedExtensions | ForEach-Object { $_.ToLowerInvariant() }
Get-ChildItem -LiteralPath $Global:UploadDir -Recurse -File -ErrorAction SilentlyContinue |
Where-Object {
-not $_.Attributes.HasFlag([IO.FileAttributes]::Hidden) -and
-not $_.Attributes.HasFlag([IO.FileAttributes]::System) -and
($exts.Count -eq 0 -or $exts -contains $_.Extension.ToLowerInvariant())
} |
Sort-Object CreationTimeUtc, LastWriteTimeUtc
}
function Publish-OneFile {
param([Parameter(Mandatory)][System.IO.FileInfo]$FileObj,
[Parameter(Mandatory)][string]$Endpoint)
$content = Read-TextFileSmart -Path $FileObj.FullName
if ($Global:TypechoConfig.MaxContentLength -gt 0 -and $content.Length -gt $Global:TypechoConfig.MaxContentLength) {
$content = $content.Substring(0, $Global:TypechoConfig.MaxContentLength)
}
$title = if ($Global:TypechoConfig.TitleFromFilename) {
Make-TitleFromPath -Path $FileObj.FullName
} else {
$firstLine = ($content -split "(`r`n|`n|`r)")[0]
if ([string]::IsNullOrWhiteSpace($firstLine)) { Make-TitleFromPath -Path $FileObj.FullName } else { $firstLine.Trim() }
}
# 可选:在正文顶部增加来源相对路径提示(若不需要,注释掉下面 4 行)
$relPrefix = Make-RelPathPrefix -Root $Global:UploadDir -Full $FileObj.FullName
if (-not [string]::IsNullOrWhiteSpace($relPrefix)) {
$content = "[source] $relPrefix|$([IO.Path]::GetFileName($FileObj.FullName))`r`n`r`n$content"
}
# 如果是 Markdown 文件,自动添加渲染提示(按你站点插件识别的标记可自行调整)
$ext = $FileObj.Extension.ToLowerInvariant()
if ($ext -in @(".md", ".markdown")) {
if ($content -notmatch '(?im)^\s*<!--\s*markdown\s*-->\s*$') {
$content = "<!--markdown-->`r`n`r`n$content"
}
}
$id = Publish-TypechoPost `
-Endpoint $Endpoint `
-User $Global:TypechoConfig.User -Pass $Global:TypechoConfig.Pass `
-BlogId $Global:TypechoConfig.BlogId -Title $title -Content $content `
-Categories $Global:TypechoConfig.DefaultCategories `
-Tags $Global:TypechoConfig.DefaultTags `
-Publish:$Global:TypechoConfig.PublishNow
return $id
}
function Move-ToCompleted {
param([Parameter(Mandatory)][System.IO.FileInfo]$FileObj)
$destDir = $Global:CompletedDir
$dateSub = (Get-Date -Format "yyyy-MM-dd")
$destDirDate = Join-Path $destDir $dateSub
if (-not (Test-Path -LiteralPath $destDirDate)) { New-Item -ItemType Directory -Path $destDirDate -Force | Out-Null }
$destPath = Join-Path $destDirDate $FileObj.Name
# 如目标已存在,追加时间戳避免覆盖
if (Test-Path -LiteralPath $destPath) {
$base = [IO.Path]::GetFileNameWithoutExtension($FileObj.Name)
$ext = [IO.Path]::GetExtension($FileObj.Name)
$ts = (Get-Date -Format "HHmmssfff")
$destPath = Join-Path $destDirDate ("{0}-{1}{2}" -f $base,$ts,$ext)
}
Move-Item -LiteralPath $FileObj.FullName -Destination $destPath -Force
return $destPath
}
function Remove-EmptySubDirs {
param([Parameter(Mandatory)][string]$Root)
if (-not (Test-Path -LiteralPath $Root)) { return }
# 取所有子目录,按层级深度从深到浅排序
$dirs = Get-ChildItem -LiteralPath $Root -Directory -Recurse -ErrorAction SilentlyContinue |
Sort-Object { $_.FullName.Split([char[]]"\/").Count } -Descending
foreach ($d in $dirs) {
try {
# 再次检查是否为空(不含任何文件或子目录)
$hasChild = Get-ChildItem -LiteralPath $d.FullName -Force -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $hasChild) {
Remove-Item -LiteralPath $d.FullName -Force -ErrorAction Stop
Write-Log "INFO" ("已删除空目录:{0}" -f $d.FullName)
}
} catch {
Write-Log "WARN" ("删除空目录失败:{0} ;原因:{1}" -f $d.FullName, $_.Exception.Message)
}
}
}
function Prepare-Endpoint {
$Endpoint = $Global:TypechoConfig.Endpoint
$User = $Global:TypechoConfig.User
$Pass = $Global:TypechoConfig.Pass
$BlogId = $Global:TypechoConfig.BlogId
if ([string]::IsNullOrWhiteSpace($Pass) -or $Pass -eq "请在此处填写API密码") {
throw "未配置 API 密码,请先编辑脚本顶部配置。"
}
Write-Log "INFO" "校验端点:$Endpoint"
$test1 = Test-TypechoXmlRpc -Endpoint $Endpoint -User $User -Pass $Pass -BlogId $BlogId
if (-not $test1.Ok) {
$ep2 = Resolve-Endpoint $Endpoint -useIndexPhp
Write-Log "WARN" ("常规端点不可用:{0};尝试备用端点:{1}" -f $test1.Reason, $ep2)
$test2 = Test-TypechoXmlRpc -Endpoint $ep2 -User $User -Pass $Pass -BlogId $BlogId
if (-not $test2.Ok) {
throw ("两条端点均失败,停止。原因:{0}" -f $test2.Reason)
}
$Endpoint = $ep2
}
Write-Log "INFO" "鉴权通过。"
return $Endpoint
}
function Process-Once {
$endpoint = Prepare-Endpoint
$files = Pick-PendingFiles
if (-not $files -or $files.Count -eq 0) {
Write-Log "INFO" "没有待处理文件。"
# 即使没有文件,也尝试清理空目录
Remove-EmptySubDirs -Root $Global:UploadDir
return
}
Write-Log "INFO" ("发现待处理文件 {0} 个。" -f $files.Count)
foreach($f in $files) {
try {
Write-Log "INFO" ("发布:{0}" -f $f.FullName)
$id = Publish-OneFile -FileObj $f -Endpoint $endpoint
Write-Log "INFO" ("发布成功:ID={0},文件:{1}" -f $id, $f.Name)
$dest = Move-ToCompleted -FileObj $f
Write-Log "INFO" ("已移动到:{0}" -f $dest)
} catch {
Write-Log "ERROR" ("发布失败:{0} ;原因:{1}" -f $f.FullName, $_.Exception.Message)
# 失败不移动,保留在待处理目录
}
}
# 处理完一轮后,清理空目录
Remove-EmptySubDirs -Root $Global:UploadDir
}
# ---------- 主入口 ----------
try {
Ensure-Directories
Write-Log "INFO" ("启动:监控目录={0};归档目录={1};RunOnce={2};PollInterval={3}s" -f $Global:UploadDir,$Global:CompletedDir,$Global:RunOnce,$Global:PollInterval)
if ($Global:RunOnce) {
Process-Once
Write-Log "INFO" "完成一次扫描,退出。"
} else {
while ($true) {
Process-Once
Start-Sleep -Seconds $Global:PollInterval
}
}
} catch {
Write-Log "FATAL" $_.Exception.Message
throw
}快速上手
- 环境:Windows + PowerShell 5.1/7+
准备:
- 修改脚本顶部配置,填好 Endpoint/User/Pass;
- 创建目录 D:\website-files\upload_file 与 upload_completed;
- 将要发布的 .md/.txt 等文件放入 upload_file,可按主题/日期建子目录;
- 右键以有权限的账户运行脚本或在控制台执行:powershell -File .\auto-upload-watch.ps1。
运行模式:
- 一次性导入:将 $Global:RunOnce = $true;
- 常驻轮询:保持 $false,并根据需要调整 $Global:PollInterval。
- 日志:D:\website-files\auto-upload.log。失败的文件会保留在 upload_file,便于重试与排查。
使用建议与注意
- 安全:脚本内含明文密码,务必限制脚本与日志文件的 NTFS 访问权限。
- 端点:若 /action/xmlrpc 被拦截,脚本会自动尝试 /index.php/action/xmlrpc;必要时在 CDN/WAF 放通。
- 编码:尽量统一保存为 UTF-8;否则可按实际更改 FallbackEncoding。
- 批量迁移:先设 PublishNow = $false,抽查合格后再改为发布。
- 标题组织:目录结构会拼接为标题前缀,形成“路径即分类”的天然组织方式。
~ ~ The End ~ ~
文章标题:用 PowerShell 把本地文本“自动投递”到 Typecho:auto-upload-watch.ps1(含完整代码)
文章链接:https://www.fangshaonian.cn/archives/111/
最后编辑:2025 年 11 月 10 日 18:46 By 方少年
许可协议: 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0)