chore: initial private ActivityWatch Russian deployment project
This commit is contained in:
Executable
+434
@@ -0,0 +1,434 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ConfigPath = 'C:\ProgramData\ActivityWatch\deployment-config.json',
|
||||
[string]$ServerHost,
|
||||
[int]$ServerPort,
|
||||
[ValidateSet('http', 'https')]
|
||||
[string]$ServerScheme,
|
||||
[string]$RulesPath,
|
||||
[string]$LogPath,
|
||||
[int]$PollSeconds,
|
||||
[int]$PulseSeconds
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
Add-Type -AssemblyName UIAutomationClient
|
||||
Add-Type -AssemblyName UIAutomationTypes
|
||||
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
|
||||
public static class NativeAwMethods {
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetForegroundWindow();
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId);
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||
public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern int GetWindowTextLength(IntPtr hWnd);
|
||||
}
|
||||
"@
|
||||
|
||||
function Get-DeploymentConfig {
|
||||
param([string]$Path)
|
||||
if ($Path -and (Test-Path -LiteralPath $Path)) {
|
||||
return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
$deploymentConfig = Get-DeploymentConfig -Path $ConfigPath
|
||||
$resolvedServerHost = if ($ServerHost) { $ServerHost } elseif ($deploymentConfig) { [string]$deploymentConfig.server.host } else { throw 'ServerHost is required.' }
|
||||
$resolvedServerPort = if ($PSBoundParameters.ContainsKey('ServerPort')) { $ServerPort } elseif ($deploymentConfig) { [int]$deploymentConfig.server.port } else { 5600 }
|
||||
$resolvedServerScheme = if ($ServerScheme) { $ServerScheme } elseif ($deploymentConfig) { [string]$deploymentConfig.server.scheme } else { 'http' }
|
||||
$resolvedRulesPath = if ($RulesPath) { $RulesPath } elseif ($deploymentConfig) { [string]$deploymentConfig.paths.rulesPath } else { 'C:\ProgramData\ActivityWatch\web-category-rules.json' }
|
||||
$resolvedPollSeconds = if ($PSBoundParameters.ContainsKey('PollSeconds')) { $PollSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pollSeconds } else { 5 }
|
||||
$resolvedPulseSeconds = if ($PSBoundParameters.ContainsKey('PulseSeconds')) { $PulseSeconds } elseif ($deploymentConfig) { [int]$deploymentConfig.collector.pulseSeconds } else { 30 }
|
||||
$resolvedLogsRoot = if ($deploymentConfig) { [string]$deploymentConfig.paths.logsRoot } else { 'C:\ProgramData\ActivityWatch\logs' }
|
||||
$resolvedLogPath = if ($LogPath) { $LogPath } else { Join-Path $resolvedLogsRoot ("browser-domains-{0}.log" -f $env:USERNAME) }
|
||||
|
||||
if (-not (Test-Path -LiteralPath $resolvedLogsRoot)) {
|
||||
New-Item -Path $resolvedLogsRoot -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
|
||||
$script:ApiBase = '{0}://{1}:{2}/api/0' -f $resolvedServerScheme, $resolvedServerHost, $resolvedServerPort
|
||||
$script:Hostname = $env:COMPUTERNAME
|
||||
$script:SessionId = (Get-Process -Id $PID).SessionId
|
||||
$script:KnownBuckets = @{}
|
||||
$script:LogPath = $resolvedLogPath
|
||||
$script:BrowserMap = @{
|
||||
msedge = 'edge'
|
||||
chrome = 'chrome'
|
||||
brave = 'brave'
|
||||
vivaldi = 'vivaldi'
|
||||
opera = 'opera'
|
||||
firefox = 'firefox'
|
||||
}
|
||||
$script:CategoryRules = @(
|
||||
@{ Name = 'work_business_systems'; Group = 'work'; Domains = @('bitrix24.ru', '1c.ru', 'sbis.ru', 'kontur.ru', 'diadoc.ru', 'nalog.gov.ru', 'gosuslugi.ru') }
|
||||
@{ Name = 'work_docs_collab'; Group = 'work'; Domains = @('office.com', 'sharepoint.com', 'docs.google.com', 'drive.google.com', 'notion.so', 'miro.com') }
|
||||
@{ Name = 'work_dev'; Group = 'work'; Domains = @('github.com', 'gitlab.com', 'bitbucket.org', 'youtrack.cloud', 'atlassian.net') }
|
||||
@{ Name = 'work_communication'; Group = 'work'; Domains = @('teams.microsoft.com', 'outlook.office.com', 'web.telegram.org', 'slack.com', 'zoom.us') }
|
||||
@{ Name = 'neutral_search_reference'; Group = 'neutral'; Domains = @('google.com', 'google.ru', 'yandex.ru', 'bing.com', 'duckduckgo.com', 'wikipedia.org') }
|
||||
@{ Name = 'neutral_news'; Group = 'neutral'; Domains = @('rbc.ru', 'tass.ru', 'ria.ru', 'kommersant.ru', 'vedomosti.ru') }
|
||||
@{ Name = 'personal_social'; Group = 'personal'; Domains = @('vk.com', 'ok.ru', 'facebook.com', 'instagram.com', 'tiktok.com', 'x.com', 'twitter.com') }
|
||||
@{ Name = 'personal_video'; Group = 'personal'; Domains = @('youtube.com', 'youtu.be', 'rutube.ru', 'twitch.tv', 'kinopoisk.ru') }
|
||||
@{ Name = 'personal_marketplace'; Group = 'personal'; Domains = @('ozon.ru', 'wildberries.ru', 'avito.ru', 'aliexpress.com', 'market.yandex.ru') }
|
||||
@{ Name = 'personal_entertainment'; Group = 'personal'; Domains = @('dzen.ru', 'pikabu.ru', 'dtf.ru', 'playground.ru') }
|
||||
)
|
||||
|
||||
function Write-CollectorLog {
|
||||
param([string]$Message)
|
||||
|
||||
try {
|
||||
Add-Content -LiteralPath $script:LogPath -Value ('{0} {1}' -f (Get-Date -Format s), $Message)
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
function Test-DomainMatch {
|
||||
param(
|
||||
[string]$Host,
|
||||
[string]$RuleDomain
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Host) -or [string]::IsNullOrWhiteSpace($RuleDomain)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
$left = $Host.ToLowerInvariant()
|
||||
$right = $RuleDomain.ToLowerInvariant()
|
||||
return $left -eq $right -or $left.EndsWith('.' + $right)
|
||||
}
|
||||
|
||||
function Get-HostFromUrl {
|
||||
param([string]$Url)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Url)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
try {
|
||||
$uri = [Uri]$Url
|
||||
$host = $uri.Host.ToLowerInvariant()
|
||||
if ($host.StartsWith('www.')) {
|
||||
return $host.Substring(4)
|
||||
}
|
||||
|
||||
return $host
|
||||
}
|
||||
catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
function Get-RootDomain {
|
||||
param([string]$Host)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Host)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$parts = $Host.Split('.')
|
||||
if ($parts.Count -le 2) {
|
||||
return $Host
|
||||
}
|
||||
|
||||
$suffix = ('{0}.{1}' -f $parts[$parts.Count - 2], $parts[$parts.Count - 1]).ToLowerInvariant()
|
||||
$compoundTlds = @('co.uk', 'com.au', 'co.jp', 'com.br', 'co.in', 'com.tr', 'com.cn')
|
||||
if (($compoundTlds -contains $suffix) -and $parts.Count -ge 3) {
|
||||
return ('{0}.{1}' -f $parts[$parts.Count - 3], $suffix).ToLowerInvariant()
|
||||
}
|
||||
|
||||
return $suffix
|
||||
}
|
||||
|
||||
function ConvertTo-NormalizedUrl {
|
||||
param([AllowNull()][string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value)) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$candidate = $Value.Trim()
|
||||
if ($candidate.Length -lt 4) {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($candidate -match '^(?i)(search|find|address and search|search with|новая вкладка|new tab)') {
|
||||
return $null
|
||||
}
|
||||
|
||||
if ($candidate -match '^(?i)(https?|file|ftp|chrome|edge|about|view-source)://') {
|
||||
return $candidate
|
||||
}
|
||||
|
||||
if ($candidate -match '^(?i)localhost([/:]|$)') {
|
||||
return "http://$candidate"
|
||||
}
|
||||
|
||||
if ($candidate -match '^[a-z0-9.-]+\.[a-z]{2,}([/:?#].*)?$') {
|
||||
return "https://$candidate"
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Load-CustomCategoryRules {
|
||||
param([string]$Path)
|
||||
|
||||
if (-not $Path -or -not (Test-Path -LiteralPath $Path)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
$parsed = Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json
|
||||
$rules = @()
|
||||
|
||||
if ($parsed.rules) {
|
||||
$sourceRules = @($parsed.rules)
|
||||
}
|
||||
elseif ($parsed -is [System.Collections.IEnumerable]) {
|
||||
$sourceRules = @($parsed)
|
||||
}
|
||||
else {
|
||||
$sourceRules = @()
|
||||
}
|
||||
|
||||
foreach ($rule in $sourceRules) {
|
||||
if (-not $rule) {
|
||||
continue
|
||||
}
|
||||
|
||||
$name = [string]$rule.name
|
||||
$group = [string]$rule.group
|
||||
$domains = @($rule.domains | ForEach-Object { ([string]$_).Trim().ToLowerInvariant() } | Where-Object { $_ })
|
||||
|
||||
if ($name -and $group -and $domains.Count -gt 0) {
|
||||
$rules += @{
|
||||
Name = $name
|
||||
Group = $group
|
||||
Domains = $domains
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($rules.Count -gt 0) {
|
||||
$script:CategoryRules = @($rules) + @($script:CategoryRules)
|
||||
Write-CollectorLog ("custom rules loaded: {0}" -f $rules.Count)
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("custom rules load failed: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
}
|
||||
|
||||
function Get-WebCategory {
|
||||
param([string]$Host)
|
||||
|
||||
foreach ($rule in $script:CategoryRules) {
|
||||
foreach ($domain in $rule.Domains) {
|
||||
if (Test-DomainMatch -Host $Host -RuleDomain $domain) {
|
||||
return [pscustomobject]@{
|
||||
Name = [string]$rule.Name
|
||||
Group = [string]$rule.Group
|
||||
Rule = [string]$domain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [pscustomobject]@{
|
||||
Name = 'uncategorized'
|
||||
Group = 'neutral'
|
||||
Rule = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ForegroundWindowContext {
|
||||
$handle = [NativeAwMethods]::GetForegroundWindow()
|
||||
if ($handle -eq [IntPtr]::Zero) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$processId = [uint32]0
|
||||
[void][NativeAwMethods]::GetWindowThreadProcessId($handle, [ref]$processId)
|
||||
if (-not $processId) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$process = Get-Process -Id ([int]$processId) -ErrorAction SilentlyContinue
|
||||
if (-not $process) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$textLength = [NativeAwMethods]::GetWindowTextLength($handle)
|
||||
$builder = [Text.StringBuilder]::new([Math]::Max($textLength + 1, 260))
|
||||
[void][NativeAwMethods]::GetWindowText($handle, $builder, $builder.Capacity)
|
||||
|
||||
return [pscustomobject]@{
|
||||
Handle = $handle
|
||||
ProcessName = $process.ProcessName.ToLowerInvariant()
|
||||
Title = $builder.ToString()
|
||||
}
|
||||
}
|
||||
|
||||
function Get-BrowserUrlFromWindow {
|
||||
param([IntPtr]$Handle)
|
||||
|
||||
$root = [System.Windows.Automation.AutomationElement]::FromHandle($Handle)
|
||||
if (-not $root) {
|
||||
return $null
|
||||
}
|
||||
|
||||
$editCondition = [System.Windows.Automation.PropertyCondition]::new(
|
||||
[System.Windows.Automation.AutomationElement]::ControlTypeProperty,
|
||||
[System.Windows.Automation.ControlType]::Edit
|
||||
)
|
||||
|
||||
$edits = $root.FindAll([System.Windows.Automation.TreeScope]::Descendants, $editCondition)
|
||||
foreach ($edit in $edits) {
|
||||
$valuePattern = $null
|
||||
if ($edit.TryGetCurrentPattern([System.Windows.Automation.ValuePattern]::Pattern, [ref]$valuePattern)) {
|
||||
$candidate = ConvertTo-NormalizedUrl -Value $valuePattern.Current.Value
|
||||
if ($candidate) {
|
||||
return $candidate
|
||||
}
|
||||
}
|
||||
|
||||
$candidateFromName = ConvertTo-NormalizedUrl -Value $edit.Current.Name
|
||||
if ($candidateFromName) {
|
||||
return $candidateFromName
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
function Ensure-Bucket {
|
||||
param(
|
||||
[string]$BucketId,
|
||||
[string]$ClientName,
|
||||
[string]$BucketType = 'web.tab.current'
|
||||
)
|
||||
|
||||
if ($script:KnownBuckets.ContainsKey($BucketId)) {
|
||||
return
|
||||
}
|
||||
|
||||
$body = @{
|
||||
client = $ClientName
|
||||
type = $BucketType
|
||||
hostname = $script:Hostname
|
||||
} | ConvertTo-Json -Compress
|
||||
|
||||
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId" -ContentType 'application/json' -Body $body | Out-Null
|
||||
$script:KnownBuckets[$BucketId] = $true
|
||||
}
|
||||
|
||||
function Send-Heartbeat {
|
||||
param(
|
||||
[string]$BucketId,
|
||||
[string]$Url,
|
||||
[string]$Title,
|
||||
[string]$BrowserKey,
|
||||
[string]$ProcessName
|
||||
)
|
||||
|
||||
$event = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
url = $Url
|
||||
title = $Title
|
||||
browser = $BrowserKey
|
||||
app = "$ProcessName.exe"
|
||||
source = 'uia-native'
|
||||
sessionId = $script:SessionId
|
||||
}
|
||||
} | ConvertTo-Json -Depth 4 -Compress
|
||||
|
||||
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$BucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
|
||||
}
|
||||
|
||||
function Send-CategoryHeartbeat {
|
||||
param(
|
||||
[string]$Url,
|
||||
[string]$Title,
|
||||
[string]$BrowserKey,
|
||||
[string]$ProcessName,
|
||||
[string]$Domain,
|
||||
[string]$RootDomain,
|
||||
[string]$Category,
|
||||
[string]$CategoryGroup,
|
||||
[string]$CategoryRule
|
||||
)
|
||||
|
||||
$bucketId = 'aw-watcher-web-category_' + $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName 'aw-watcher-web-category' -BucketType 'aw.web.category'
|
||||
|
||||
$event = @{
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
|
||||
duration = 0
|
||||
data = @{
|
||||
url = $Url
|
||||
title = $Title
|
||||
browser = $BrowserKey
|
||||
app = "$ProcessName.exe"
|
||||
domain = $Domain
|
||||
rootDomain = $RootDomain
|
||||
category = $Category
|
||||
categoryGroup = $CategoryGroup
|
||||
categoryRule = $CategoryRule
|
||||
source = 'uia-native'
|
||||
sessionId = $script:SessionId
|
||||
}
|
||||
} | ConvertTo-Json -Depth 4 -Compress
|
||||
|
||||
Invoke-RestMethod -Method Post -Uri "$($script:ApiBase)/buckets/$bucketId/heartbeat?pulsetime=$resolvedPulseSeconds" -ContentType 'application/json' -Body $event | Out-Null
|
||||
}
|
||||
|
||||
Load-CustomCategoryRules -Path $resolvedRulesPath
|
||||
Write-CollectorLog ("collector started against {0}" -f $script:ApiBase)
|
||||
|
||||
while ($true) {
|
||||
try {
|
||||
$context = Get-ForegroundWindowContext
|
||||
if ($context -and $script:BrowserMap.ContainsKey($context.ProcessName)) {
|
||||
$url = Get-BrowserUrlFromWindow -Handle $context.Handle
|
||||
if ($url) {
|
||||
$browserKey = $script:BrowserMap[$context.ProcessName]
|
||||
$domain = Get-HostFromUrl -Url $url
|
||||
if (-not $domain) {
|
||||
$domain = 'unknown'
|
||||
}
|
||||
|
||||
$rootDomain = Get-RootDomain -Host $domain
|
||||
if (-not $rootDomain) {
|
||||
$rootDomain = $domain
|
||||
}
|
||||
|
||||
$category = Get-WebCategory -Host $domain
|
||||
$bucketId = 'aw-watcher-web-{0}_{1}' -f $browserKey, $script:Hostname
|
||||
Ensure-Bucket -BucketId $bucketId -ClientName ('aw-watcher-web-' + $browserKey)
|
||||
Send-Heartbeat -BucketId $bucketId -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName
|
||||
Send-CategoryHeartbeat -Url $url -Title $context.Title -BrowserKey $browserKey -ProcessName $context.ProcessName -Domain $domain -RootDomain $rootDomain -Category $category.Name -CategoryGroup $category.Group -CategoryRule $category.Rule
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-CollectorLog ("collector error: {0}" -f $_.Exception.Message)
|
||||
}
|
||||
|
||||
Start-Sleep -Seconds $resolvedPollSeconds
|
||||
}
|
||||
Reference in New Issue
Block a user