PowerShell 腳本應用 ── 安全地自動化日誌調查、封存與報表化

· · PowerShell, Windows, 自動化, 日誌調查, 維運改善, 既有資產活用

1. 首先要掌握的事

上一篇整理了 PowerShell 的基本命令、管線、CSV 輸出、JSON、.ps1 腳本,以及用 -WhatIf 進行安全確認。本文接續前一篇,做一個實務上更好用、稍微大一點的腳本。題材是下面這種維運作業:

  1. 調查日誌
  2. 把錯誤行整理成 CSV
  3. 把舊日誌列成封存候選清單
  4. 必要時搬移舊日誌
  5. 把執行結果留存為紀錄

PowerShell 應用的重點不是把命令寫得又長又複雜,而是建立以下這種流程:

  1. 把設定分開管理
  2. 先做讀取處理
  3. 留下輸出
  4. 把變更處理拆成函式
  5. -WhatIf 先做預演
  6. 最後再考慮自動執行

自動化不是「省掉人的確認」。而是把該確認的地方固定下來,讓每次都留下同樣的紀錄。

另外,本文出現的程式碼,已整理成可以直接執行的範例一套(完成版腳本、設定檔、建立測試用假日誌環境的腳本,以及驗證調查、預演、搬移的 Pester 測試)公開在 GitHub 上。

powershell-script-log-maintenance-automation - komurasoft-blog-samples (GitHub)

2. 本文要做的東西

本文要做一個叫 Invoke-LogMaintenance.ps1 的腳本。

主要功能如下:

功能 內容
日誌搜尋 搜尋指定資料夾以下的 .log
指定期間 只調查最近 N 天內有更新的日誌
錯誤擷取 擷取 ERRORWARNFATAL 等行
CSV 輸出 把搜尋結果存成 log-hits.csv
舊日誌清單 把比 N 天更舊的日誌存成 archive-targets.csv
封存 把舊日誌搬移到另一個資料夾
預演執行 指定 -Preview 時不搬移,只顯示預定動作
執行紀錄 保存 transcript、summary JSON、結果 CSV

另外,本文不做刪除。第一篇應用篇只做到比刪除更安全的「搬移」為止。

3. 資料夾結構

以下面這種結構為例:

C:\Ops
  Invoke-LogMaintenance.ps1
  log-maintenance.json

C:\App\Logs
  app.log
  batch.log
  old
    app-202401.log

C:\App\Reports
  20260602-030000
    log-hits.csv
    archive-targets.csv
    archive-result.csv
    summary.json
    transcript.txt

C:\App\Archive
  20260602-030000
    old
      app-202401.log

把腳本本體和設定檔分開,換環境時替換起來會比較輕鬆。可以做成開發環境用 C:\Test\Logs、正式環境用 D:\App\Logs,只需要改路徑的運作方式。

4. 建立設定檔

先建立 log-maintenance.json

{
  "LogPath": "C:\\App\\Logs",
  "OutputPath": "C:\\App\\Reports",
  "Days": 7,
  "Patterns": [
    "ERROR",
    "WARN",
    "FATAL"
  ],
  "ArchiveDays": 90,
  "ArchivePath": "C:\\App\\Archive"
}

各項目的意義如下:

項目 意義
LogPath 要調查的日誌資料夾
OutputPath 報表輸出位置
Days 要調查最近幾天的日誌
Patterns 要搜尋的字串/模式
ArchiveDays 比幾天更舊的日誌要列為封存對象
ArchivePath 封存目的地資料夾

寫成 JSON 之後,就能在不修改腳本本體的情況下變更條件。

PowerShell 可以用 ConvertFrom-Json 把 JSON 當成物件來處理;反過來,要把處理結果留存為 JSON 時則使用 ConvertTo-JsonConvertTo-Json 是把物件轉換成 JSON 字串的 cmdlet,處理較深的層級時,指定 -Depth 就很重要。

5. 先只做讀取處理

不要一開始就寫搬移處理,先只做找日誌、輸出成 CSV 這件事。

$config = Get-Content .\log-maintenance.json -Raw -Encoding UTF8 | ConvertFrom-Json

$since = (Get-Date).AddDays(-[int]$config.Days)

$files = Get-ChildItem -LiteralPath $config.LogPath -Filter *.log -File -Recurse |
  Where-Object { $_.LastWriteTime -ge $since }

$files |
  Select-Object FullName, Length, LastWriteTime

接著搜尋日誌的內容。

$patterns = [string[]]$config.Patterns

Select-String -LiteralPath ($files | Select-Object -ExpandProperty FullName) -Pattern $patterns |
  Select-Object Path, LineNumber, Pattern, Line |
  Export-Csv .\log-hits.csv -NoTypeInformation -Encoding UTF8

到這裡都只是讀取。即使在正式環境的資料夾中測試,也先在這個階段停下來。

6. 把舊日誌列表化

接著,把封存對象列表化。

$limit = (Get-Date).AddDays(-[int]$config.ArchiveDays)

$targets = Get-ChildItem -LiteralPath $config.LogPath -Filter *.log -File -Recurse |
  Where-Object { $_.LastWriteTime -lt $limit } |
  Sort-Object LastWriteTime

$targets |
  Select-Object FullName, Length, LastWriteTime |
  Export-Csv .\archive-targets.csv -NoTypeInformation -Encoding UTF8

這個階段也還不搬移。要看看 archive-targets.csv,確認對象數量是不是太多,資料夾是不是選錯了。

7. 把變更處理拆成函式

像搬移這種變更處理,要和讀取處理分開。

在 PowerShell 中,只要在函式加上 SupportsShouldProcess,就能使用 -WhatIf-Confirm-WhatIf 會不實際執行、只顯示「預定要變更什麼」,-Confirm 則是在執行前顯示確認的機制。詳細內容可以參考 Microsoft Learn 的 about_Functions_CmdletBindingAttribute

function Move-OldLogFile {
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "Medium")]
  param(
    [Parameter(Mandatory)]
    [System.IO.FileInfo[]]$File,

    [Parameter(Mandatory)]
    [string]$SourceRoot,

    [Parameter(Mandatory)]
    [string]$ArchiveRoot
  )

  foreach ($item in $File) {
    $relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $item.FullName)
    $destination = Join-Path $ArchiveRoot $relativePath
    $destinationDirectory = Split-Path -Path $destination -Parent

    if ($PSCmdlet.ShouldProcess($item.FullName, "Move to $destination")) {
      if (-not [System.IO.Directory]::Exists($destinationDirectory)) {
        [System.IO.Directory]::CreateDirectory($destinationDirectory) | Out-Null
      }

      Move-Item -LiteralPath $item.FullName -Destination $destination -ErrorAction Stop

      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Moved"
        Message     = ""
      }
    }
    else {
      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Preview"
        Message     = ""
      }
    }
  }
}

重點是把 $PSCmdlet.ShouldProcess() 放在 Move-Item 的正前方。要不要變更的判斷,不是放在函式外面,而是放在真正變更之前。

8. 完成版腳本

以下是把上面內容整理起來的完成版。檔名設為 Invoke-LogMaintenance.ps1

# Invoke-LogMaintenance.ps1
#Requires -Version 7.0

[CmdletBinding()]
param(
  [ValidateNotNullOrEmpty()]
  [string]$ConfigPath = ".\log-maintenance.json",

  [switch]$Preview,

  [switch]$SkipArchive,

  [switch]$SkipTranscript
)

Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"

function Ensure-Directory {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$Path
  )

  if (-not [System.IO.Directory]::Exists($Path)) {
    [System.IO.Directory]::CreateDirectory($Path) | Out-Null
  }
}

function Import-LogMaintenanceConfig {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$Path
  )

  if (-not (Test-Path -LiteralPath $Path)) {
    throw "Config file not found: $Path"
  }

  $config = Get-Content -LiteralPath $Path -Raw -Encoding UTF8 | ConvertFrom-Json

  foreach ($name in @("LogPath", "OutputPath", "Days", "Patterns", "ArchiveDays", "ArchivePath")) {
    if (-not ($config.PSObject.Properties.Name -contains $name)) {
      throw "Config value missing: $name"
    }
  }

  if ([string]::IsNullOrWhiteSpace([string]$config.LogPath)) {
    throw "LogPath is empty."
  }

  if (-not (Test-Path -LiteralPath $config.LogPath)) {
    throw "LogPath not found: $($config.LogPath)"
  }

  if ([string]::IsNullOrWhiteSpace([string]$config.OutputPath)) {
    throw "OutputPath is empty."
  }

  if ([string]::IsNullOrWhiteSpace([string]$config.ArchivePath)) {
    throw "ArchivePath is empty."
  }

  if (@($config.Patterns).Count -eq 0) {
    throw "Patterns is empty."
  }

  if ([int]$config.Days -lt 1) {
    throw "Days must be 1 or greater."
  }

  if ([int]$config.ArchiveDays -lt 1) {
    throw "ArchiveDays must be 1 or greater."
  }

  return $config
}

function Export-CsvWithHeader {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [object[]]$InputObject,

    [Parameter(Mandatory)]
    [string]$Path,

    [Parameter(Mandatory)]
    [string[]]$Header
  )

  if ($InputObject.Count -gt 0) {
    $InputObject |
      Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding UTF8
  }
  else {
    ($Header -join ",") |
      Set-Content -LiteralPath $Path -Encoding UTF8
  }
}

function Get-LogHit {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$LogPath,

    [Parameter(Mandatory)]
    [ValidateRange(1, 3650)]
    [int]$Days,

    [Parameter(Mandatory)]
    [string[]]$Pattern
  )

  $since = (Get-Date).AddDays(-$Days)

  $files = @(
    Get-ChildItem -LiteralPath $LogPath -Filter *.log -File -Recurse -ErrorAction Stop |
      Where-Object { $_.LastWriteTime -ge $since }
  )

  Write-Verbose "Recent log files: $($files.Count)"

  if ($files.Count -eq 0) {
    return @()
  }

  $paths = $files | Select-Object -ExpandProperty FullName

  Select-String -LiteralPath $paths -Pattern $Pattern -ErrorAction Stop |
    ForEach-Object {
      [pscustomobject]@{
        Path       = $_.Path
        LineNumber = $_.LineNumber
        Pattern    = $_.Pattern
        Line       = $_.Line.Trim()
      }
    }
}

function Get-OldLogFile {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)]
    [string]$LogPath,

    [Parameter(Mandatory)]
    [ValidateRange(1, 3650)]
    [int]$ArchiveDays
  )

  $limit = (Get-Date).AddDays(-$ArchiveDays)

  Get-ChildItem -LiteralPath $LogPath -Filter *.log -File -Recurse -ErrorAction Stop |
    Where-Object { $_.LastWriteTime -lt $limit } |
    Sort-Object LastWriteTime
}

function Move-OldLogFile {
  [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = "Medium")]
  param(
    [Parameter(Mandatory)]
    [System.IO.FileInfo[]]$File,

    [Parameter(Mandatory)]
    [string]$SourceRoot,

    [Parameter(Mandatory)]
    [string]$ArchiveRoot
  )

  foreach ($item in $File) {
    $relativePath = [System.IO.Path]::GetRelativePath($SourceRoot, $item.FullName)
    $destination = Join-Path $ArchiveRoot $relativePath
    $destinationDirectory = Split-Path -Path $destination -Parent

    if (Test-Path -LiteralPath $destination) {
      $name = [System.IO.Path]::GetFileNameWithoutExtension($item.Name)
      $ext = $item.Extension
      $destination = Join-Path $destinationDirectory ("{0}_{1:yyyyMMddHHmmss}{2}" -f $name, $item.LastWriteTime, $ext)
    }

    if ($PSCmdlet.ShouldProcess($item.FullName, "Move to $destination")) {
      Ensure-Directory -Path $destinationDirectory

      Move-Item -LiteralPath $item.FullName -Destination $destination -ErrorAction Stop

      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Moved"
        Message     = ""
      }
    }
    else {
      [pscustomobject]@{
        Source      = $item.FullName
        Destination = $destination
        Status      = "Preview"
        Message     = ""
      }
    }
  }
}

$config = Import-LogMaintenanceConfig -Path $ConfigPath

$runStamp = Get-Date -Format "yyyyMMdd-HHmmss"
$reportDir = Join-Path ([string]$config.OutputPath) $runStamp
Ensure-Directory -Path $reportDir

$transcriptStarted = $false
$transcriptPath = Join-Path $reportDir "transcript.txt"

try {
  if (-not $SkipTranscript) {
    Start-Transcript -Path $transcriptPath -Force | Out-Null
    $transcriptStarted = $true
  }

  Write-Host "Report directory: $reportDir"

  $hits = @(
    Get-LogHit `
      -LogPath ([string]$config.LogPath) `
      -Days ([int]$config.Days) `
      -Pattern ([string[]]$config.Patterns)
  )

  $hitCsv = Join-Path $reportDir "log-hits.csv"
  Export-CsvWithHeader `
    -InputObject $hits `
    -Path $hitCsv `
    -Header @("Path", "LineNumber", "Pattern", "Line")

  $oldFiles = @(
    Get-OldLogFile `
      -LogPath ([string]$config.LogPath) `
      -ArchiveDays ([int]$config.ArchiveDays)
  )

  $archiveTargets = @(
    $oldFiles |
      Select-Object FullName, Length, LastWriteTime
  )

  $archiveTargetCsv = Join-Path $reportDir "archive-targets.csv"
  Export-CsvWithHeader `
    -InputObject $archiveTargets `
    -Path $archiveTargetCsv `
    -Header @("FullName", "Length", "LastWriteTime")

  $moveResults = @()

  if ($SkipArchive) {
    Write-Host "Archive skipped."
  }
  elseif ($oldFiles.Count -eq 0) {
    Write-Host "No archive targets."
  }
  else {
    $archiveRunRoot = Join-Path ([string]$config.ArchivePath) $runStamp

    $moveResults = @(
      Move-OldLogFile `
        -File $oldFiles `
        -SourceRoot ([string]$config.LogPath) `
        -ArchiveRoot $archiveRunRoot `
        -WhatIf:$Preview
    )
  }

  $archiveResultCsv = Join-Path $reportDir "archive-result.csv"
  Export-CsvWithHeader `
    -InputObject $moveResults `
    -Path $archiveResultCsv `
    -Header @("Source", "Destination", "Status", "Message")

  $summary = [pscustomobject]@{
    CheckedAt          = (Get-Date).ToString("s")
    ComputerName       = $env:COMPUTERNAME
    LogPath            = [string]$config.LogPath
    ReportDirectory    = $reportDir
    HitCount           = $hits.Count
    ArchiveTargetCount = $oldFiles.Count
    ArchiveResultCount = $moveResults.Count
    Preview            = [bool]$Preview
    SkipArchive        = [bool]$SkipArchive
  }

  $summaryPath = Join-Path $reportDir "summary.json"
  $summary |
    ConvertTo-Json -Depth 5 |
    Set-Content -LiteralPath $summaryPath -Encoding UTF8

  Write-Host "Finished."
  Write-Host "Hits: $($hits.Count)"
  Write-Host "Archive targets: $($oldFiles.Count)"
}
catch {
  $errorPath = Join-Path $reportDir "error.txt"
  $_ | Out-String | Set-Content -LiteralPath $errorPath -Encoding UTF8
  Write-Error "Failed: $($_.Exception.Message)"
  exit 1
}
finally {
  if ($transcriptStarted) {
    Stop-Transcript | Out-Null
  }
}

9. 執行範例

先在不執行封存的情況下,只做日誌調查。

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -SkipArchive

接著,確認封存的預定動作。

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -Preview

加上 -Preview 之後,舊日誌的搬移處理會被當成 -WhatIf 處理。

這個時間點要確認的檔案有下面 3 個:

  • log-hits.csv
  • archive-targets.csv
  • archive-result.csv

沒問題之後,就把 -Preview 拿掉再執行。

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json

想看詳細內容時可以加上 -Verbose

.\Invoke-LogMaintenance.ps1 -ConfigPath .\log-maintenance.json -Preview -Verbose

10. 會輸出的檔案

執行之後,會在 OutputPath 底下建立像 C:\App\Reports\20260602-030000 這種帶日期時間的資料夾。

裡面會輸出下列檔案:

檔案 內容
log-hits.csv 被判定為錯誤/警告的行
archive-targets.csv 被列為封存對象的舊日誌
archive-result.csv 搬移結果,或 Preview 結果
summary.json 件數與執行條件的摘要
transcript.txt PowerShell 工作階段的紀錄
error.txt 發生錯誤時的詳細內容

Start-Transcript 是把 PowerShell 工作階段的命令與主控台輸出記錄成文字檔的 cmdlet。在維運腳本中,之後要確認「何時、在什麼條件下、輸出了什麼」會比較容易。

11. 用工作排程器定期執行

手動執行沒有問題之後,就可以用工作排程器定期執行。

以下是每天凌晨 3 點執行的範例。

$scriptPath = "C:\Ops\Invoke-LogMaintenance.ps1"
$configPath = "C:\Ops\log-maintenance.json"

$action = New-ScheduledTaskAction `
  -Execute "pwsh.exe" `
  -Argument "-NoProfile -File `"$scriptPath`" -ConfigPath `"$configPath`"" `
  -WorkingDirectory "C:\Ops"

$trigger = New-ScheduledTaskTrigger -Daily -At 3:00

Register-ScheduledTask `
  -TaskName "AppLogMaintenance" `
  -Action $action `
  -Trigger $trigger `
  -Description "Collect app log errors and archive old logs"

New-ScheduledTaskAction 會建立代表工作要執行的命令的物件,New-ScheduledTaskTrigger 則建立每天、每週、登入時等啟動條件。最後用 Register-ScheduledTask 把工作註冊到本機電腦。

在正式環境中,還要確認下面這些項目:

  • 執行使用者有日誌資料夾的讀取權限
  • 有封存目的地的寫入權限
  • pwsh.exe 的路徑已設定好
  • 符合腳本的執行原則與簽章規則
  • 手動執行與工作排程器執行的結果一致
  • 失敗時可以查看 error.txt 或工作排程器的歷程紀錄

12. 常見的卡關情況

症狀 原因 對策
找不到日誌 LogPath 設錯了 Test-PathGet-ChildItem 確認
CSV 是空的 對象期間內沒有符合的日誌 Days 放寬後再確認
文字出現亂碼 日誌的文字編碼和預期不同 確認輸入輸出的文字編碼
在工作排程器裡跑不動 執行使用者或工作目錄不同 確認 WorkingDirectory 與權限
封存對象太多 ArchiveDays 設太短 archive-targets.csv 再調整
搬移目的地和預期不同 沒理解相對路徑的保留規則 -Preview 確認 Destination
只有在正式環境失敗 權限、原則或被鎖定檔案的差異 確認 error.txttranscript.txt

特別是用工作排程器執行時,「自己執行時」和「工作排程器實際執行的使用者」可能不一樣。如果手動可以動、但工作排程器會失敗,請先懷疑權限與工作目錄。

13. 改造時的思考方式

這個腳本的前提是,與其直接原封不動使用,更適合依現場情況慢慢改造。以下列出常見的改造範例。

想做的事 改造位置
也想把 .txt 納入對象 修改 Get-ChildItem -Filter *.log
也想看 ERROR 前後幾行 Select-String 的結果為基礎,用 Get-Content 取得周圍的行
想先壓縮再封存 Move-OldLogFile 之前加上 Compress-Archive
想做電子郵件通知 summary.json 為基礎加上通知處理
想依應用程式分開設定 準備多份 JSON,把工作分開
想連刪除都自動化 先用搬移運作一段時間再評估

不過,一開始就把所有功能都塞進去並不安全。維運腳本重要的不是功能多,而是失敗時能不能追查。

14. 現場維運檢查清單

在定期執行 PowerShell 腳本之前,要確認下列各項:

  • 先用 -SkipArchive 只執行過讀取處理
  • 接著用 -Preview 確認過搬移預定動作
  • archive-targets.csv 的對象是合理的
  • archive-result.csvDestination 符合預期
  • 輸出資料夾中留下了帶日期時間的紀錄
  • 確認發生錯誤時會留下 error.txt
  • 確認過工作排程器執行使用者的權限
  • 確認過執行原則、簽章、公司內部規則
  • 不要一開始就刪除,先用搬移的方式運作
  • 已經決定好萬一要復原時該搬回哪裡

15. 結語

雖然說是 PowerShell 的應用,但不需要用到很多複雜的語法。實務上真正有效的,是先建立好下面這種模式:

  • 把設定拆到 JSON 裡
  • 先做讀取處理
  • 用 CSV 與 JSON 留下紀錄
  • 把變更處理拆成函式
  • 準備相當於 -WhatIf 的預演機制
  • 用 transcript 與 error.txt 讓事後可以追查
  • 先手動執行確認,之後才定期執行

這次的腳本以日誌調查與封存為題材,但這種思考方式也可以用在其他業務上,例如:

  • 檔案整理
  • 報表輸出
  • CSV 彙整
  • 批次程式替換
  • 舊資產的盤點
  • 每日/每月的維運確認

PowerShell 用一行命令就很方便,但如果要用在業務上,遵守下面這個順序會比較安全:

先看 → 記錄 → 預演 → 執行 → 留下紀錄

做成這種形式之後,PowerShell 就不只是縮短作業時間的工具,而能當成讓維運更穩定的小型業務應用程式來使用。

參考連結

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

用 PowerShell 自動化日誌調查時,應該從什麼開始寫?
不要一開始就寫搬移或刪除這類變更處理,先只做讀取處理。先完成用 Get-ChildItem 找出對象日誌、用 Select-String 擷取錯誤行並輸出成 CSV 這部分。即使是在正式環境的資料夾中測試,也要先在這個讀取階段暫停,確認結果。之後再逐步進到把封存對象列表化、把搬移處理寫成函式,這樣比較安全。
PowerShell 的 -WhatIf 要怎麼寫進腳本裡?
在函式的 CmdletBinding 屬性加上 SupportsShouldProcess,該函式就能使用 -WhatIf 與 -Confirm。重點是把 $PSCmdlet.ShouldProcess() 放在真正執行變更的處理(例如 Move-Item)之前,讓「要不要變更」的判斷就發生在變更動作之前。本文的腳本把 -Preview 開關以 -WhatIf:$Preview 的形式傳入,就能在不搬移的情況下先確認預定動作。
PowerShell 腳本手動執行沒問題,但在工作排程器中卻失敗,原因是什麼?
常見原因是自己執行時的身分和工作排程器實際執行的使用者不同。要確認執行使用者是否有日誌資料夾的讀取權限、封存目的地的寫入權限,工作目錄(WorkingDirectory)是否正確,pwsh.exe 的路徑是否設定好,以及是否符合執行原則與簽章規則。失敗時,也要能靠 error.txt、transcript.txt 或工作排程器的歷程紀錄追查原因。
可以連舊日誌的刪除都自動化嗎?
不建議一開始就把刪除自動化。本文的腳本也只做到比刪除更安全的「搬移」為止。建議先用搬移的方式運作一段時間,確認 archive-targets.csv、archive-result.csv 這些紀錄都沒有問題後,再考慮把刪除自動化。也要事先決定好,萬一要復原時該搬回哪裡。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽