تطبيقات PowerShell المتقدمة — أتمتة آمنة لتحقيق السجلات (logs) وأرشفتها وإصدار التقارير عنها

· · PowerShell, Windows, أتمتة, تحقيق السجلات, تحسين التشغيل, الاستفادة من الأصول القائمة

1. الأساسيات التي ينبغي فهمها أولًا

في المقال السابق تناولنا أوامر PowerShell الأساسية، وخط الأنابيب (pipeline)، وإخراج CSV، وJSON، وسكربتات .ps1، والتحقق الآمن عبر -WhatIf. في هذا المقال، وكمتابعة لذلك، سنبني سكربتًا أكبر قليلًا وسهل الاستخدام في العمل الفعلي. الموضوع هو مهمة تشغيلية (operational task) من هذا النوع:

  1. تحقيق السجلات (logs)
  2. تجميع أسطر الأخطاء في ملف CSV
  3. سرد السجلات القديمة كمرشحة للأرشفة
  4. نقل السجلات القديمة عند الحاجة
  5. حفظ نتائج التنفيذ كدليل (audit trail)

المهم في تطبيقات PowerShell المتقدمة ليس إطالة الأوامر، بل بناء التدفق التالي:

  1. فصل الإعدادات
  2. بناء معالجة القراءة
  3. حفظ المخرجات
  4. فصل معالجة التغيير إلى دوال (functions)
  5. إجراء تجربة (rehearsal) باستخدام -WhatIf
  6. النظر في التشغيل التلقائي في النهاية

الأتمتة لا تعني «الاستغناء عن تحقق الإنسان». بل تعني تثبيت النقطة التي يجب التحقق عندها، وضمان بقاء نفس الدليل في كل مرة.

علمًا بأن الكود الوارد في هذا المقال منشور على GitHub كمجموعة عينات جاهزة للتشغيل مباشرة (السكربت النهائي، وملف الإعدادات، وسكربت إنشاء بيئة سجلات وهمية (dummy)، واختبارات Pester للتحقق من التحقيق والتجربة والنقل).

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

2. ما سنبنيه في هذا المقال

في هذا المقال سنبني سكربتًا باسم Invoke-LogMaintenance.ps1.

وفيما يلي أبرز وظائفه.

الوظيفة المحتوى
البحث عن السجلات البحث عن ملفات .log ضمن المجلد المحدد
تحديد الفترة الزمنية تحقيق السجلات التي تم تحديثها خلال آخر N يومًا فقط
استخراج الأخطاء استخراج الأسطر التي تحتوي على ERROR أو WARN أو FATAL وغيرها
إخراج CSV حفظ نتائج البحث في log-hits.csv
سرد السجلات القديمة حفظ السجلات الأقدم من N يومًا في archive-targets.csv
الأرشفة نقل السجلات القديمة إلى مجلد منفصل
التشغيل التجريبي عند تحديد -Preview، يُعرض المخطط فقط دون نقل فعلي
تسجيل التنفيذ حفظ transcript وملف JSON للملخص (summary) ونتائج 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، يمكن التعامل مع JSON ككائن (object) باستخدام ConvertFrom-Json. وعلى العكس، عند الحاجة لحفظ نتيجة المعالجة بصيغة JSON، تُستخدم ConvertTo-Json. ConvertTo-Json هي cmdlet تحوّل الكائنات إلى نصوص JSON، وعند التعامل مع مستويات تداخل عميقة يصبح تحديد -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. فصل معالجة التغيير إلى دالة (function)

افصل معالجة التغيير، مثل النقل، عن معالجة القراءة.

في PowerShell، إضافة SupportsShouldProcess إلى دالة (function) تجعلها قادرة على التعامل مع -WhatIf وConfirm-. تعرض -WhatIf «ما الذي سيتم تغييره» دون التنفيذ الفعلي، بينما -Confirm هي الآلية التي تعرض تأكيدًا قبل التنفيذ. للمزيد من التفاصيل، راجع about_Functions_CmdletBindingAttribute على Microsoft Learn.

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.

الملفات الثلاثة التي يجب مراجعتها في هذه المرحلة هي:

  • 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 هي cmdlet تُستخدم لتسجيل أوامر جلسة PowerShell ومخرجات وحدة التحكم (console) في ملف نصي. في السكربتات التشغيلية، يسهّل ذلك التحقق لاحقًا من «متى تم التنفيذ، وتحت أي شروط، وما الذي ظهر».

11. التشغيل الدوري عبر Task Scheduler

إذا لم تكن هناك مشكلة عند التشغيل اليدوي، يمكن التشغيل الدوري عبر Task Scheduler.

فيما يلي مثال يبدأ بتشغيل يومي في الساعة 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 كائنًا يمثّل الأمر الذي ستنفذه المهمة (task)، بينما تُنشئ New-ScheduledTaskTrigger شروط بدء التشغيل مثل يوميًا أو أسبوعيًا أو عند تسجيل الدخول. وأخيرًا، تُسجَّل المهمة على الجهاز المحلي عبر Register-ScheduledTask.

في بيئة الإنتاج، تحقق أيضًا من النقاط التالية.

  • يملك المستخدم المُنفِّذ صلاحية قراءة مجلد السجلات
  • توجد صلاحية كتابة في وجهة الأرشفة
  • مسار pwsh.exe معرَّف بشكل صحيح
  • السكربت متوافق مع سياسة التنفيذ (execution policy) وقواعد التوقيع
  • يُعطي التشغيل اليدوي وتشغيل المهمة نفس النتيجة
  • يمكن مراجعة error.txt وسجل تنفيذ المهام عند الفشل

12. المشكلات الشائعة

العرض السبب المعالجة
لا يُعثر على السجلات LogPath غير صحيح تحقق باستخدام Test-Path وGet-ChildItem
ملف CSV فارغ لا توجد سجلات ضمن الفترة المستهدفة وسّع نطاق Days وتحقق مرة أخرى
ظهور النصوص بأحرف مشوّهة (mojibake) ترميز أحرف السجل مختلف عمّا هو متوقع تحقق من ترميز الإدخال والإخراج
لا يعمل عبر Task Scheduler اختلاف المستخدم المُنفِّذ أو مجلد العمل تحقق من WorkingDirectory والصلاحيات
عدد أهداف الأرشفة كبير جدًا قيمة ArchiveDays قصيرة جدًا راجع archive-targets.csv وعدّل القيمة
وجهة النقل مختلفة عمّا هو متوقع عدم فهم قاعدة الحفاظ على المسار النسبي تحقق من Destination عبر -Preview
الفشل يقع في بيئة الإنتاج فقط اختلاف في الصلاحيات أو السياسات أو وجود ملفات مقفلة راجع error.txt وtranscript.txt

على وجه الخصوص، عند التشغيل عبر Task Scheduler، قد يختلف «المستخدم الذي شغّلت السكربت تحته بنفسك» عن «المستخدم الذي تعمل تحته المهمة». إذا كان السكربت يعمل يدويًا لكنه يفشل كمهمة، فابدأ بالشك في الصلاحيات ومجلد العمل.

13. طريقة التفكير عند التعديل (customization)

هذا السكربت مُصمَّم على افتراض تعديله تدريجيًا ليتناسب مع بيئة العمل الفعلية، بدلًا من استخدامه كما هو. فيما يلي أمثلة شائعة على التعديل.

ما تريد فعله مكان التعديل
تضمين ملفات .txt أيضًا تعديل Get-ChildItem -Filter *.log
رؤية عدد من الأسطر قبل وبعد ERROR جلب الأسطر المحيطة عبر Get-Content بناءً على نتائج Select-String
ضغط الملفات قبل الأرشفة إضافة Compress-Archive قبل Move-OldLogFile
إرسال إشعارات عبر البريد الإلكتروني إضافة معالجة إشعارات بناءً على summary.json
فصل الإعدادات حسب كل تطبيق تجهيز عدة ملفات JSON وتقسيم المهام (tasks)
أتمتة الحذف أيضًا تشغيل عملية النقل لفترة معينة أولًا، ثم النظر في الأمر لاحقًا

لكن من الأسلم عدم إضافة كل شيء منذ البداية. في السكربتات التشغيلية، إمكانية التتبع عند وقوع الفشل أهم من كثرة الوظائف.

14. قائمة تحقق للتشغيل الميداني

قبل تشغيل سكربت PowerShell بشكل دوري، تحقق من النقاط التالية.

  • نفّذت معالجة القراءة فقط أولًا باستخدام -SkipArchive
  • ثم تحققت من خطة النقل باستخدام -Preview
  • كانت الأهداف في archive-targets.csv معقولة
  • كانت قيمة Destination في archive-result.csv مطابقة للمتوقع
  • بقي دليل يحمل طابعًا زمنيًا في مجلد الإخراج
  • تحققت من بقاء error.txt عند وقوع خطأ
  • تحققت من صلاحيات المستخدم المُنفِّذ للمهمة
  • تحققت من سياسة التنفيذ والتوقيع والقواعد الداخلية للشركة
  • شغّلت النظام بالنقل أولًا وليس بالحذف المباشر
  • حدّدت وجهة الاستعادة في حال الحاجة للتراجع

15. الخلاصة

حتى عند الحديث عن تطبيقات PowerShell المتقدمة، لا حاجة لاستخدام قدر كبير من الصياغات المعقدة. ما ينفع فعليًا في العمل هو تجهيز هذا النمط (pattern):

  • فصل الإعدادات في ملف JSON
  • بناء معالجة القراءة أولًا
  • حفظ الأدلة عبر CSV وJSON
  • فصل معالجة التغيير إلى دوال
  • تجهيز تجربة مماثلة لـ -WhatIf
  • إتاحة التتبع عبر transcript وerror.txt
  • التحقق عبر التشغيل اليدوي قبل التشغيل الدوري

يتناول سكربت هذا المقال تحقيق السجلات وأرشفتها كموضوع، لكن طريقة التفكير هذه قابلة للتطبيق على مهام أخرى أيضًا:

  • تنظيم الملفات
  • إصدار التقارير (reports)
  • تجميع بيانات CSV
  • استبدال الملفات الدفعي (batch)
  • جرد الأصول القديمة
  • التحقق التشغيلي اليومي والشهري

PowerShell مفيد حتى كأوامر من سطر واحد، لكن عند استخدامه في العمل، من الأسلم الالتزام بالترتيب التالي:

النظر ← التسجيل ← التجربة ← التنفيذ ← حفظ الدليل

عند اتباع هذا الشكل، لا يظل PowerShell مجرد أداة لاختصار العمل، بل يصبح تطبيقًا تشغيليًا صغيرًا يُستخدم لتحقيق استقرار العمليات.

روابط مرجعية

أحدث المقالات التي تشترك في نفس الوسوم. عمّق فهمك بمواضيع مرتبطة.

دليل المراجعة الشاملة لـ VBA و Excel macro والأدوات الداخليّة استعدادًا لإيقاف VBScript - الجرد / الكشف الساكن / سجلّات التشغيل / اختيار البديل / الاختبار / النشر التدريجيّ

ملخّص عمليّ على صفحة واحدة لمسار الاستعداد لإيقاف VBScript تدريجيًّا: جرد VBA و Excel macro والأدوات الداخليّة، الكشف الساكن، تجميع سجلّا...

ما يجب التحقّق منه عندما لا يعمل ActiveX على Office 2024 / Microsoft 365 - الترتيب العمليّ لتغطية التعطيل الافتراضيّ، 32bit / 64bit، تسجيل COM، DLL التابعة، ووصولًا إلى IE mode

دليل عمليّ لتشخيص توقّف ActiveX على Office 2024 و Microsoft 365، يرتّب الفحوص من التعطيل الافتراضيّ إلى تطابق 32bit / 64bit وتسجيل COM وD...

ترتبط هذه المقالة بشكل طبيعي بصفحات الخدمات التالية.

الأسئلة الشائعة

أسئلة شائعة حول موضوع هذه المقالة.

عند أتمتة تحقيق السجلات باستخدام PowerShell، من أين ينبغي البدء؟
لا تبدأ بكتابة عمليات التغيير مثل النقل أو الحذف مباشرة، بل ابنِ أولًا معالجة القراءة فقط. أكمل أولًا الجزء الذي يبحث عن السجلات المستهدفة باستخدام Get-ChildItem، ويستخرج أسطر الأخطاء باستخدام Select-String، ويصدّرها إلى CSV. حتى عند التجربة في مجلد بيئة الإنتاج، توقف عند مرحلة القراءة هذه وتحقق من النتائج أولًا. بعد ذلك، من الأنسب التقدم تدريجيًا نحو سرد أهداف الأرشفة ثم تحويل معالجة النقل إلى دالة (function).
كيف يمكن دمج -WhatIf الخاص بـ PowerShell داخل السكربت؟
بإضافة SupportsShouldProcess إلى سمة CmdletBinding الخاصة بالدالة (function)، تصبح تلك الدالة قادرة على استخدام -WhatIf وConfirm-. النقطة الجوهرية هي وضع $PSCmdlet.ShouldProcess() مباشرة قبل عملية التغيير الفعلية (مثل Move-Item)، بحيث يُتخذ قرار التغيير من عدمه قُبيل التغيير مباشرة. في سكربت هذا المقال، يُمرَّر المفتاح -Preview كـ -WhatIf:$Preview، مما يتيح استعراض الخطة فقط دون تنفيذ عملية النقل الفعلية.
لماذا يعمل سكربت PowerShell عند التشغيل اليدوي لكنه يفشل عند تشغيله عبر Task Scheduler؟
السبب النمطي هو اختلاف المستخدم الذي تعمل تحته المهمة (task) عن المستخدم الذي شغّلت السكربت يدويًا تحته. تحقق مما إذا كان لدى ذلك المستخدم صلاحية قراءة مجلد السجلات وصلاحية كتابة في وجهة الأرشفة، وما إذا كان مجلد العمل (WorkingDirectory) صحيحًا، وما إذا كان مسار pwsh.exe معرَّفًا بشكل صحيح، وما إذا كانت سياسة التنفيذ (execution policy) وقواعد التوقيع متوافقة. من المهم أيضًا التأكد من إمكانية تتبع حالات الفشل عبر error.txt وtranscript.txt وسجل تنفيذ المهام (task history).
هل يجوز أتمتة حذف السجلات القديمة أيضًا؟
لا يُنصح بأتمتة الحذف منذ البداية. سكربت هذا المقال أيضًا يتوقف عند «النقل» الآمن دون الوصول إلى الحذف. من الأفضل تشغيل عملية النقل لفترة معينة أولًا، ومراجعة الأدلة مثل archive-targets.csv وarchive-result.csv للتأكد من عدم وجود مشكلات، ثم النظر في أتمتة الحذف بعد ذلك. من المهم أيضًا تحديد وجهة الاستعادة مسبقًا في حال احتيج إلى التراجع.

الملف الشخصي للمؤلف

صفحة الملف الشخصي لمؤلف المقالة.

غو كومورا

مؤسّس شركة كومورا سوفت ذ.م.م.

يركّز على تطوير برامج ويندوز، والاستشارات التقنية، والتحقيق في الأخطاء، ويتميّز في المشاريع التي تبقى فيها الأصول القديمة ناشطة، وفي تشخيص الأعطال التي يصعب تحديد سببها.

روابط عامة

العودة إلى المدونة