用 PowerShell 把本地文本“自动投递”到 Typecho:auto-upload-watch.ps1(含完整代码)

用 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+
  • 准备:

    1. 修改脚本顶部配置,填好 Endpoint/User/Pass;
    2. 创建目录 D:\website-files\upload_file 与 upload_completed;
    3. 将要发布的 .md/.txt 等文件放入 upload_file,可按主题/日期建子目录;
    4. 右键以有权限的账户运行脚本或在控制台执行: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  ~  ~


 赏 
感谢您的支持,我会继续努力哒!
支付宝收款码
tips
(*) 3 + 5 =
快来做第一个评论的人吧~