Automating SharePoint page analytics with PowerShell

 


If you have ever clicked Analytics on a SharePoint page and thought, “This is helpful… but I need this for every page,” you are in good company. SharePoint gives solid per-page insights in the UI, but exporting page analytics at scale quickly turns into a lot of manual clicking.

The good news is that modern SharePoint page analytics are available through Graph-backed analytics endpoints that SharePoint exposes via its _api/v2.x REST surface.
That means you can script what the UI already knows, then ship it straight into CSV, JSON, Power BI, or whatever reporting workflow your team uses.

This post walks through two practical scripts:

  1. Page summary report: all-time + last 7 days (reliable), plus 30/90 day rollups computed from daily buckets (more reliable than relying on pre-aggregated 90-day windows). 
  2. Daily time series report: per page, per day, with views and timeSpentInSeconds, perfect for trend charts and campaign tracking. 

Why this matters (and who should care)

If you publish content, you need feedback loops

Page analytics tell you what content is landing and what is not. That helps you:

  • Double down on the pages people actually read
  • Clean up or retire pages nobody visits
  • Prove (with data) that a campaign or hub site is working

If you manage SharePoint, you need scale

Site owners, comms teams, and admins often want the same thing: page-level analytics for an entire site. Doing that manually does not scale, and it is easy to miss patterns.

These scripts give you a repeatable, auditable way to pull page analytics in bulk using endpoints designed for item activity stats and interval reporting. 


Quick feature primer: what the analytics endpoints actually return

SharePoint’s page/file analytics are built on the itemAnalytics and itemActivityStat model. In plain English:

  • Views are typically returned as access.actionCount
  • Unique viewers are typically returned as access.actorCount
  • Time spent appears as access.timeSpentInSeconds when available

Microsoft calls out that the “convenient windows” are allTime and lastSevenDays, and for custom windows you should use an interval API. 

That lines up with the behavior you already saw: 30/90 day rollups can be inconsistent depending on the endpoint and the item, so computing your own rollups from daily buckets is often the most dependable route. 


What you will build

Script 1: Page Analytics Rollup (site-wide)

Outputs one row per page with:

  • All-time views and unique viewers
  • Last 7 days views and unique viewers
  • Last 30 and last 90 days rollups computed from daily buckets
  • CSV + JSON export

This approach leans on the endpoints that are consistently populated and then uses daily interval stats to fill in the rest. 

Script 2: Daily Page Analytics (site-wide)

Outputs one row per page per day with:

  • Views (actionCount)
  • Unique viewers (actorCount)
  • Time spent (timeSpentInSeconds)
  • Start/end timestamps for the day window
  • CSV + JSON export

The daily breakdown is ideal for dashboards and trend lines.


Prerequisites and setup (once)

1) Install PnP.PowerShell

Install-Module PnP.PowerShell -Scope CurrentUser

2) Permissions and access

You will need access to the target site and its Site Pages library. The scripts authenticate to SharePoint and call Graph-backed endpoints through SharePoint’s REST v2 surface. 

3) Pick your scope

Decide if you want:

  • All pages in Site Pages
  • Only News posts (filter by PromotedState = 2)

Tip: Most comms teams want News only; most admins want everything.


How to use the scripts (step-by-step)

Step 1: Set the site URL and output paths

Each script has a few variables at the top:

  • $SiteUrl (your target site)
  • $DaysBack (how far back you want daily buckets)
  • $OutCsv / $OutJson (output files)

Step 2: Connect

The scripts use:

Connect-PnPOnline -Url $SiteUrl -Interactive
``

You can swap to app-only or certificate auth later if you want automation.

Step 3: Run it

Run the script, then open the output in Excel or import into Power BI.


Script 1: Page Analytics Summary

# Requires: PnP.PowerShell
# Install-Module PnP.PowerShell -Scope CurrentUser


param(
    [Parameter(Mandatory=$false)]
    [string]$SiteUrl = "https://contoso.sharepoint.com/sites/TheSiteImReportingOn",

    [Parameter(Mandatory=$false)]
    [int]$DaysBack = 90,

    [Parameter(Mandatory=$false)]
    [string]$ClientId = "00000000-0000-0000-0000-000000000000",
[Parameter(Mandatory=$false)] [string]$OutJson = (Join-Path ([Environment]::GetFolderPath('UserProfile')) "Downloads\page-analytics$(Get-Date -Format "yyyy-MM-dd").json"), [Parameter(Mandatory=$false)] [string]$OutCsv = (Join-Path ([Environment]::GetFolderPath('UserProfile')) "Downloads\page-analytics$(Get-Date -Format "yyyy-MM-dd").csv"), [switch]$VerboseUrls ) # 1) Connect Import-Module PnP.PowerShell Connect-PnPOnline -Url $SiteUrl -Interactive -ClientId $ClientId $tenantHost = ([uri]$SiteUrl).Host # IDs needed for v2.1 endpoints $siteId = (Invoke-PnPSPRestMethod -Method Get -Url "$SiteUrl/_api/site/id").value $webId = (Invoke-PnPSPRestMethod -Method Get -Url "$SiteUrl/_api/web/id").value $listId = (Invoke-PnPSPRestMethod -Method Get -Url "$SiteUrl/_api/web/lists/GetByTitle('Site Pages')/id").value # Pull pages $items = Get-PnPListItem -List "Site Pages" -PageSize 2000 ` -Fields "FileLeafRef","FileRef","Title","UniqueId","FSObjType","PromotedState" $pages = $items | Where-Object { $_["FSObjType"] -eq 0 -and $_["FileLeafRef"] -like "*.aspx" } # Date range for reporting $startIso = (Get-Date).AddDays(-1 * $DaysBack).ToUniversalTime().ToString("o") $endIso = (Get-Date).ToUniversalTime().ToString("o") # For interval calls we’ll use daily buckets over (up to) 90 days. $endUtc = (Get-Date).ToUniversalTime().Date $startUtc = $endUtc.AddDays(-89) # inclusive, ~90 days worth of daily buckets function Get-IntervalBuckets { param( [Parameter(Mandatory)] [string]$SiteUrl, [Parameter(Mandatory)] [string]$TenantHost, [Parameter(Mandatory)] [string]$SiteId, [Parameter(Mandatory)] [string]$WebId, [Parameter(Mandatory)] [string]$ListId, [Parameter(Mandatory)] [Guid] $ItemUniqueId, [Parameter(Mandatory)] [datetime]$StartDateUtc, [Parameter(Mandatory)] [datetime]$EndDateUtc, [ValidateSet("day","week")] [string]$Interval = "day", [switch]$VerboseUrls ) $startStr = $StartDateUtc.ToString("yyyy-MM-dd") $endStr = $EndDateUtc.ToString("yyyy-MM-dd") $url = "$SiteUrl/_api/v2.1/sites/$TenantHost,$SiteId,$WebId/lists/{$ListId}/items('$ItemUniqueId')" + "/getActivitiesByInterval(startDateTime='$startStr',endDateTime='$endStr',interval='$Interval')" if ($VerboseUrls) { Write-Host $url } $resp = Invoke-PnPSPRestMethod -Method Get -Url $url return @($resp.value | Sort-Object startDateTime) } function Sum-Buckets { param([Parameter(Mandatory)] $Buckets) $views = 0 $actors = 0 $time = 0 foreach ($b in $Buckets) { if ($null -ne $b.access) { if ($null -ne $b.access.actionCount) { $views += [int]$b.access.actionCount } if ($null -ne $b.access.actorCount) { $actors += [int]$b.access.actorCount } if ($null -ne $b.access.timeSpentInSeconds){ $time += [int]$b.access.timeSpentInSeconds } } } [pscustomobject]@{ Views = $views Unique = $actors TimeSpentSeconds = $time } } $results = foreach ($p in $pages) { $fileLeaf = $p["FileLeafRef"] $serverRel = $p["FileRef"] $fullUrl = "https://$tenantHost$serverRel" $uniqueId = [Guid]$p["UniqueId"] # Title fallback if empty $title = $p["Title"] if ([string]::IsNullOrWhiteSpace($title)) { $title = $fileLeaf } try { # All time + last 7 via analytics endpoints $allTimeUrl = "$SiteUrl/_api/v2.1/sites/$tenantHost,$siteId,$webId/lists/{$listId}/items('$uniqueId')/analytics/allTime" $last7Url = "$SiteUrl/_api/v2.1/sites/$tenantHost,$siteId,$webId/lists/{$listId}/items('$uniqueId')/analytics/lastSevenDays" $allTime = Invoke-PnPSPRestMethod -Method Get -Url $allTimeUrl $last7 = Invoke-PnPSPRestMethod -Method Get -Url $last7Url # 30/90 by interval buckets (reliable custom window approach) 【1-48c3ef】 $buckets90 = Get-IntervalBuckets ` -SiteUrl $SiteUrl -TenantHost $tenantHost ` -SiteId $siteId -WebId $webId -ListId $listId ` -ItemUniqueId $uniqueId ` -StartDateUtc $startUtc -EndDateUtc $endUtc ` -Interval "day" -VerboseUrls:$VerboseUrls $last30Buckets = $buckets90 | Select-Object -Last 30 $sum30 = Sum-Buckets $last30Buckets $sum90 = Sum-Buckets $buckets90 [pscustomobject]@{ Title = $title Url = $fullUrl UniqueId = $uniqueId AllTimeViews = $allTime.access.actionCount AllTimeUniqueViewers = $allTime.access.actorCount ViewsLast7Days = $last7.access.actionCount UniqueLast7Days = $last7.access.actorCount ViewsLast30Days = $sum30.Views UniqueLast30Days = $sum30.Unique ViewsLast90Days = $sum90.Views UniqueLast90Days = $sum90.Unique RangeStartUtc = $startIso RangeEndUtc = $endIso } } catch { [pscustomobject]@{ Title = $title Url = $fullUrl UniqueId = $uniqueId Error = $_.Exception.Message } } } $results | Export-Csv -NoTypeInformation -Encoding UTF8 $OutCsv $results | ConvertTo-Json -Depth 10 | Out-File -Encoding UTF8 $OutJson Write-Host "Done. CSV: $OutCsv" Write-Host "Done. JSON: $OutJson"

What this script is best at

  • Quick “top pages” lists
  • Weekly reporting
  • Comparing recent performance versus historical performance

Tip: Keep the daily-bucket rollup logic for 30/90 day totals. It is more stable than relying on a pre-aggregated 90-day window that may not be fully populated for every page. 


Script 2: Daily Page Analytics

# Requires: PnP.PowerShell
# Install-Module PnP.PowerShell -Scope CurrentUser

param(
    [string]$SiteUrl  = "https://contoso.sharepoint.com/sites/Site_to_Report",
    [int]$DaysBack    = 1000,
    [string]$ClientId = "00000000-0000-0000-0000-000000000000",
# If set, removes rows where Views=0 AND TimeSpentInSeconds=0 [switch]$ExcludeZeroDays, # If set, include only News posts (PromotedState = 2) [switch]$NewsOnly, # If set, prints request URLs [switch]$VerboseUrls ) # ---------------------------- # Connect # ---------------------------- Connect-PnPOnline -Url $SiteUrl -Interactive -ClientId $ClientId $tenantHost = ([uri]$SiteUrl).Host # Get site title for file naming $siteTitle = (Get-PnPWeb).Title if ([string]::IsNullOrWhiteSpace($siteTitle)) { $siteTitle = "Site" } # Site creation date (UTC) $webInfo = Invoke-PnPSPRestMethod -Method Get -Url "$SiteUrl/_api/web?`$select=Created" $siteCreatedUtc = ([datetime]$webInfo.Created).ToUniversalTime() # Sanitize site title for use in file name (remove invalid file name characters) $invalid = [IO.Path]::GetInvalidFileNameChars() $siteTitleSafe = -join ($siteTitle.ToCharArray() | Where-Object { $invalid -notcontains $_ }) $siteTitleSafe = $siteTitleSafe.Trim() if ([string]::IsNullOrWhiteSpace($siteTitleSafe)) { $siteTitleSafe = "Site" } # Output paths (include site title + run date + daysback) $runDate = (Get-Date -Format 'yyyy-MM-dd') $outBase = "page-daily-analytics-$siteTitleSafe-$runDate-$($DaysBack)days" $downloads = Join-Path ([Environment]::GetFolderPath('UserProfile')) "Downloads" [string]$OutCsv = Join-Path $downloads "$outBase.csv" [string]$OutJson = Join-Path $downloads "$outBase.json" # ---------------------------- # IDs needed for v2.1 endpoints # ---------------------------- $siteId = (Invoke-PnPSPRestMethod -Method Get -Url "$SiteUrl/_api/site/id").value $webId = (Invoke-PnPSPRestMethod -Method Get -Url "$SiteUrl/_api/web/id").value $listId = (Invoke-PnPSPRestMethod -Method Get -Url "$SiteUrl/_api/web/lists/GetByTitle('Site Pages')/id").value # Global reporting window (UTC day boundaries) $endUtc = (Get-Date).ToUniversalTime().Date $startUtc = $endUtc.AddDays(-1 * ($DaysBack - 1)) # inclusive function To-UtcDateTime { param([Parameter(Mandatory=$true)] $dt) $d = [datetime]$dt if ($d.Kind -eq 'Unspecified') { return [datetime]::SpecifyKind($d, 'Utc') } return $d.ToUniversalTime() } function Get-WeekStartMondayUtc { param([Parameter(Mandatory=$true)] [datetime]$DateUtc) # PowerShell DayOfWeek: Sunday=0, Monday=1, ... Saturday=6 # Offset so Monday is start of week $dow = [int]$DateUtc.DayOfWeek $offset = ($dow + 6) % 7 return $DateUtc.Date.AddDays(-1 * $offset) } function Get-PageDailyBuckets { param( [Parameter(Mandatory)] [Guid]$ItemUniqueId, [Parameter(Mandatory)] [datetime]$StartUtc, [Parameter(Mandatory)] [datetime]$EndUtc ) $startStr = $StartUtc.ToString("yyyy-MM-dd") $endStr = $EndUtc.ToString("yyyy-MM-dd") $url = "$SiteUrl/_api/v2.1/sites/$tenantHost,$siteId,$webId/lists/{$listId}/items('$ItemUniqueId')" + "/getActivitiesByInterval(startDateTime='$startStr',endDateTime='$endStr',interval='day')" if ($VerboseUrls) { Write-Host $url } $resp = Invoke-PnPSPRestMethod -Method Get -Url $url return @($resp.value | Sort-Object startDateTime) } function Get-UserString { param($FieldValue) # "Editor" can be a FieldUserValue, string, or null depending on context if ($null -eq $FieldValue) { return $null } # Try common properties first if ($FieldValue.PSObject.Properties.Name -contains "Email" -and $FieldValue.Email) { return $FieldValue.Email } if ($FieldValue.PSObject.Properties.Name -contains "LookupValue" -and $FieldValue.LookupValue) { return $FieldValue.LookupValue } # Fallback return [string]$FieldValue } # ---------------------------- # Get pages (include fields needed for new columns) # ---------------------------- $items = Get-PnPListItem -List "Site Pages" -PageSize 2000 ` -Fields "FileLeafRef","FileRef","Title","UniqueId","FSObjType","PromotedState","Created","FirstPublishedDate","Modified","Editor" $pages = $items | Where-Object { $_["FSObjType"] -eq 0 -and $_["FileLeafRef"] -like "*.aspx" } if ($NewsOnly) { $pages = $pages | Where-Object { $_["PromotedState"] -eq 2 } } # ---------------------------- # Build output (one row per page per day) # ---------------------------- $rows = New-Object System.Collections.Generic.List[object] $counter = 0 $total = $pages.Count foreach ($p in $pages) { $counter++ $fileLeaf = $p["FileLeafRef"] $serverRel = $p["FileRef"] $pageUrl = "https://$tenantHost$serverRel" $uniqueId = [Guid]$p["UniqueId"] $title = $p["Title"] if ($null -eq $title -or [string]::IsNullOrWhiteSpace($title)) { $title = $fileLeaf } # PageType derived from PromotedState $pageType = if ($p["PromotedState"] -eq 2) { "News" } else { "Page" } # Use Created date as existence boundary $createdUtc = To-UtcDateTime $p["Created"] # Modified fields $modifiedUtc = $null if ($p["Modified"]) { $modifiedUtc = (To-UtcDateTime $p["Modified"]) } $modifiedBy = Get-UserString $p["Editor"] # FirstPublishedDate for output only (no filtering) $firstPublishedUtc = $null if ($p.FieldValues.ContainsKey("FirstPublishedDate") -and $p["FirstPublishedDate"]) { $firstPublishedUtc = To-UtcDateTime $p["FirstPublishedDate"] } # Skip if created after reporting window end if ($createdUtc -gt $endUtc) { if ($VerboseUrls) { Write-Host "Skipping (created after window): $title | Created: $createdUtc" } continue } # Effective start for this page = max(global start, created) $effectiveStartUtc = $startUtc if ($createdUtc -gt $effectiveStartUtc) { $effectiveStartUtc = $createdUtc } try { $buckets = Get-PageDailyBuckets -ItemUniqueId $uniqueId -StartUtc $effectiveStartUtc -EndUtc $endUtc foreach ($b in $buckets) { $bucketStartUtc = To-UtcDateTime $b.startDateTime # Safety: never emit rows before Created date if ($bucketStartUtc -lt $createdUtc) { continue } $views = 0 $actors = 0 $time = 0 if ($null -ne $b.access) { if ($null -ne $b.access.actionCount) { $views = [int]$b.access.actionCount } if ($null -ne $b.access.actorCount) { $actors = [int]$b.access.actorCount } if ($null -ne $b.access.timeSpentInSeconds) { $time = [int]$b.access.timeSpentInSeconds } } if ($ExcludeZeroDays -and $views -eq 0 -and $time -eq 0) { continue } $weekStartUtc = Get-WeekStartMondayUtc -DateUtc $bucketStartUtc $monthUtc = $bucketStartUtc.ToString("yyyy-MM") $rows.Add([pscustomobject]@{ SiteTitle = $siteTitle SiteUrl = $SiteUrl SiteCreatedUtc = $siteCreatedUtc.ToString("o") PageTitle = $title FileLeafRef = $fileLeaf PageType = $pageType PageUrl = $pageUrl PageUniqueId = $uniqueId DateUtc = $bucketStartUtc.ToString("yyyy-MM-dd") WeekStartUtc = $weekStartUtc.ToString("yyyy-MM-dd") MonthUtc = $monthUtc StartDateTimeUtc = $b.startDateTime EndDateTimeUtc = $b.endDateTime Views = $views UniqueActors = $actors TimeSpentInSeconds = $time CreatedUtc = $createdUtc.ToString("o") FirstPublishedUtc = if ($null -ne $firstPublishedUtc) { $firstPublishedUtc.ToString("o") } else { $null } ModifiedUtc = if ($null -ne $modifiedUtc) { $modifiedUtc.ToString("o") } else { $null } ModifiedBy = $modifiedBy WasThrottled = $b.incompleteData.wasThrottled ResultsPending = $b.incompleteData.resultsPending NotSupported = $b.incompleteData.notSupported }) | Out-Null } Write-Host "[$counter/$total] OK: $title" } catch { $rows.Add([pscustomobject]@{ SiteTitle = $siteTitle SiteUrl = $SiteUrl PageTitle = $title FileLeafRef = $fileLeaf PageType = $pageType PageUrl = $pageUrl PageUniqueId = $uniqueId CreatedUtc = $createdUtc.ToString("o") FirstPublishedUtc = if ($null -ne $firstPublishedUtc) { $firstPublishedUtc.ToString("o") } else { $null } ModifiedUtc = if ($null -ne $modifiedUtc) { $modifiedUtc.ToString("o") } else { $null } ModifiedBy = $modifiedBy Error = $_.Exception.Message }) | Out-Null Write-Host "[$counter/$total] ERROR: $title -> $($_.Exception.Message)" -ForegroundColor Yellow } } # ---------------------------- # Export # ---------------------------- $rows | Export-Csv -NoTypeInformation -Encoding UTF8 $OutCsv $rows | ConvertTo-Json -Depth 10 | Out-File -Encoding UTF8 $OutJson Write-Host "" Write-Host "Done." Write-Host "CSV : $OutCsv" Write-Host "JSON: $OutJson"

Why this script is powerful This format is gold for dashboards. You can chart:

  • Daily spikes after an email or Teams announcement
  • Slow-burn content that keeps getting steady traffic
  • Whether people are actually spending time on the page, not just clicking it

Daily activity stats are returned as an array of itemActivityStat entries for the interval you request. 


Real-world scenarios (with a few tips)

Scenario 1: Comms campaign reporting

You post a news article, send a link in Teams, and want to know what happened next.

Use Script 2 and look for:

  • A view spike the day the message went out
  • Whether time spent increases (people actually read it)

Tip: Add a column in Excel called “Campaign” and tag your page URLs. Then pivot by campaign and date for a simple performance view.

Scenario 2: Content cleanup and governance

You suspect your site has a lot of outdated pages.

Use Script 1 and sort by:

  • AllTimeViews ascending
  • ViewsLast30Days ascending

Tip: Combine “low views” with “last modified date” from the Site Pages list if you want a stronger “stale content” signal.

Scenario 3: Measuring evergreen value

Some pages never go viral, but quietly deliver value.

Use Script 2 and filter for:

  • Pages with steady weekly views
  • Low spikes but consistent time spent

Tip: Those pages are great candidates for navigation links and hub highlights.

Scenario 4: Proving adoption for a new hub or landing page

If you launched a new landing page, leadership will ask, “Is it being used?”

Use both scripts

  • Script 1 for an executive-friendly summary
  • Script 2 for the day-by-day adoption curve

Microsoft’s analytics model is built to support both rollups and interval-based analysis, which is why combining these approaches works well. 


Tips and tricks (less pain, better output)

1) Keep the data set manageable

Daily analytics can explode in size quickly.

  • Filter to News only if that is what you care about
  • Exclude rows where views and time are both zero (optional)

2) Watch for “resultsPending”

Your sample payload includes flags like resultsPending. That is a hint the backend may not have finished processing stats for that interval yet. If you run a daily report at 8 AM, yesterday’s numbers might still be settling. 

3) Time spent can be zero even when views are not

This is not you. Time spent is not always populated consistently across all items and intervals. It is often present as a field, but it may remain zero depending on how the backend computed it for that item type. 

4) Build for automation later

Once you trust the output, the next step is to run it on a schedule:

  • Azure Automation
  • Task Scheduler
  • A simple pipeline that drops files into a SharePoint library

SharePoint’s REST v2 surface is designed for calling Graph-backed operations through SharePoint endpoints, which is friendly to automation once auth is sorted out. 


Common questions (because somebody will ask)

“Does this include views from Teams and Viva Connections?”

If the page is opened and rendered, the activity is recorded as item analytics, regardless of whether the page was accessed through SharePoint directly or linked from another surface. The analytics model is tied to the item.

“Are unique viewers truly unique across 30/90 days?”

Daily actorCount is “unique actors for that day.” If you sum actorCount across many days, repeat viewers across multiple days can be counted more than once. That is expected for interval buckets. 


Conclusion

Once you move past one-page-at-a-time analytics, the real value shows up when you can ask bigger questions. Which pages are carrying the site. Which posts peaked and disappeared. Which content keeps helping people week after week. These scripts let you answer those questions with real numbers, not guesswork. They also give you flexibility. Use the rollup script when you need quick summaries and executive-friendly reporting. Use the daily script when you want trend lines, campaign measurement, and a clean dataset you can chart in Power BI. Best of all, you are building on the same Graph-backed analytics model SharePoint uses in the UI, so you are not inventing a new reporting system. You are simply unlocking it at scale, then shaping it into something your team can actually use.