feat(linkedin-thought-leadership): v1.0.0 — initial open-source import
Build LinkedIn thought leadership with algorithmic understanding, strategic consistency, and AI-assisted content creation. Updated for the January 2026 360Brew algorithm change. 16 agents, 25 commands, 6 skills, 9 hooks, 24 reference docs. Personal data sanitized: voice samples generalized to template, high-engagement posts cleared, region-specific references replaced with placeholders. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7194a37129
commit
39f8b275a6
143 changed files with 32662 additions and 0 deletions
599
plugins/linkedin-thought-leadership/scripts/analytics/package-lock.json
generated
Normal file
599
plugins/linkedin-thought-leadership/scripts/analytics/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
{
|
||||
"name": "linkedin-analytics",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "linkedin-analytics",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"csv-parse": "^5.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz",
|
||||
"integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz",
|
||||
"integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz",
|
||||
"integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz",
|
||||
"integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz",
|
||||
"integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz",
|
||||
"integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz",
|
||||
"integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz",
|
||||
"integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz",
|
||||
"integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.19.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.7.tgz",
|
||||
"integrity": "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/csv-parse": {
|
||||
"version": "5.6.0",
|
||||
"resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-5.6.0.tgz",
|
||||
"integrity": "sha512-l3nz3euub2QMg5ouu5U09Ew9Wf6/wQ8I++ch1loQ0ljmzhmfZYrH9fflS22i/PQEvsPvxCwxgz5q7UB8K1JO4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||
"integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.2",
|
||||
"@esbuild/android-arm": "0.27.2",
|
||||
"@esbuild/android-arm64": "0.27.2",
|
||||
"@esbuild/android-x64": "0.27.2",
|
||||
"@esbuild/darwin-arm64": "0.27.2",
|
||||
"@esbuild/darwin-x64": "0.27.2",
|
||||
"@esbuild/freebsd-arm64": "0.27.2",
|
||||
"@esbuild/freebsd-x64": "0.27.2",
|
||||
"@esbuild/linux-arm": "0.27.2",
|
||||
"@esbuild/linux-arm64": "0.27.2",
|
||||
"@esbuild/linux-ia32": "0.27.2",
|
||||
"@esbuild/linux-loong64": "0.27.2",
|
||||
"@esbuild/linux-mips64el": "0.27.2",
|
||||
"@esbuild/linux-ppc64": "0.27.2",
|
||||
"@esbuild/linux-riscv64": "0.27.2",
|
||||
"@esbuild/linux-s390x": "0.27.2",
|
||||
"@esbuild/linux-x64": "0.27.2",
|
||||
"@esbuild/netbsd-arm64": "0.27.2",
|
||||
"@esbuild/netbsd-x64": "0.27.2",
|
||||
"@esbuild/openbsd-arm64": "0.27.2",
|
||||
"@esbuild/openbsd-x64": "0.27.2",
|
||||
"@esbuild/openharmony-arm64": "0.27.2",
|
||||
"@esbuild/sunos-x64": "0.27.2",
|
||||
"@esbuild/win32-arm64": "0.27.2",
|
||||
"@esbuild/win32-ia32": "0.27.2",
|
||||
"@esbuild/win32-x64": "0.27.2"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
|
||||
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "linkedin-analytics",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"description": "CLI tool for parsing LinkedIn analytics CSV exports",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "node --import tsx --test tests/*.test.ts",
|
||||
"start": "node --import tsx src/cli.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"csv-parse": "^5.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
311
plugins/linkedin-thought-leadership/scripts/analytics/src/cli.ts
Normal file
311
plugins/linkedin-thought-leadership/scripts/analytics/src/cli.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
import { parseLinkedInCSV } from "./parsers/csv-parser.js";
|
||||
import {
|
||||
getAnalyticsRoot,
|
||||
ensureDirectories,
|
||||
saveBatch,
|
||||
loadAllPosts,
|
||||
} from "./utils/storage.js";
|
||||
import { detectAlerts } from "./utils/alerts.js";
|
||||
import { mean, standardDeviation } from "./utils/stats.js";
|
||||
import { generateWeeklyReport, getCurrentISOWeek } from "./reports/weekly.js";
|
||||
import { join } from "node:path";
|
||||
import { existsSync } from "node:fs";
|
||||
import type { PostMetrics } from "./models/types.js";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
function parseOption(args: string[], flag: string): string | undefined {
|
||||
const idx = args.indexOf(flag);
|
||||
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : undefined;
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
LinkedIn Analytics CLI
|
||||
|
||||
Usage:
|
||||
node build/cli.js import <filename> Import a CSV export
|
||||
node build/cli.js report [--week W] Generate weekly report
|
||||
node build/cli.js trends [--period P] [--metric M] Show trends and alerts
|
||||
|
||||
Options:
|
||||
--week W ISO week (e.g., 2026-W05), defaults to current week
|
||||
--period P Time period: "week" | "month" | "quarter" | "all" (default: "month")
|
||||
--metric M Metric to analyze: "impressions" | "reactions" | "comments" | "shares" | "clicks" | "engagementRate" (default: "impressions")
|
||||
|
||||
Examples:
|
||||
node build/cli.js import linkedin-export-2026-01-20.csv
|
||||
node build/cli.js report --week 2026-W04
|
||||
node build/cli.js trends --period quarter --metric engagementRate
|
||||
`);
|
||||
}
|
||||
|
||||
async function handleImport(root: string, args: string[]) {
|
||||
const filename = args[1];
|
||||
|
||||
if (!filename) {
|
||||
console.error("Error: Missing filename argument");
|
||||
console.error("Usage: node build/cli.js import <filename>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const fullPath = join(root, "exports", filename);
|
||||
|
||||
if (!existsSync(fullPath)) {
|
||||
console.error(`Error: File not found: ${fullPath}`);
|
||||
console.error(`\nMake sure the CSV file is placed in: ${join(root, "exports")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Importing ${filename}...`);
|
||||
|
||||
try {
|
||||
const batch = parseLinkedInCSV(fullPath, filename);
|
||||
const savedFilename = saveBatch(root, batch);
|
||||
|
||||
console.log("\nImport successful!");
|
||||
console.log("─────────────────────────────────────");
|
||||
console.log(`Posts imported: ${batch.postCount}`);
|
||||
console.log(`Date range: ${batch.dateRange.from} to ${batch.dateRange.to}`);
|
||||
console.log(`Batch ID: ${batch.batchId}`);
|
||||
console.log(`Saved to: posts/${savedFilename}`);
|
||||
|
||||
// Run alert detection on imported posts
|
||||
const alerts = detectAlerts(batch.posts, "impressions");
|
||||
|
||||
if (alerts.length > 0) {
|
||||
console.log("\nImmediate alerts detected:");
|
||||
console.log("─────────────────────────────────────");
|
||||
for (const alert of alerts.slice(0, 5)) {
|
||||
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "ℹ️";
|
||||
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
|
||||
}
|
||||
|
||||
if (alerts.length > 5) {
|
||||
console.log(`\n... and ${alerts.length - 5} more alerts`);
|
||||
}
|
||||
} else {
|
||||
console.log("\nNo anomalies detected in imported data.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error parsing CSV: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleReport(root: string, args: string[]) {
|
||||
const weekOption = parseOption(args, "--week");
|
||||
const week = weekOption || getCurrentISOWeek();
|
||||
|
||||
console.log(`Generating weekly report for ${week}...`);
|
||||
|
||||
try {
|
||||
const report = generateWeeklyReport(root, week);
|
||||
|
||||
console.log("\nWeekly Report");
|
||||
console.log("═════════════════════════════════════");
|
||||
console.log(`Week: ${report.week}`);
|
||||
console.log(`Generated at: ${new Date(report.generatedAt).toLocaleString()}`);
|
||||
console.log();
|
||||
|
||||
console.log("Summary");
|
||||
console.log("─────────────────────────────────────");
|
||||
console.log(`Total posts: ${report.summary.totalPosts}`);
|
||||
console.log(`Total impressions: ${report.summary.totalImpressions.toLocaleString()}`);
|
||||
console.log(`Total reactions: ${report.summary.totalReactions.toLocaleString()}`);
|
||||
console.log(`Total comments: ${report.summary.totalComments.toLocaleString()}`);
|
||||
console.log(`Total shares: ${report.summary.totalShares.toLocaleString()}`);
|
||||
console.log(`Total clicks: ${report.summary.totalClicks.toLocaleString()}`);
|
||||
console.log(`Avg engagement: ${report.summary.avgEngagementRate.toFixed(2)}%`);
|
||||
console.log(`Avg impressions: ${Math.round(report.summary.avgImpressionsPerPost).toLocaleString()} per post`);
|
||||
console.log();
|
||||
|
||||
if (report.topPerformers.length > 0) {
|
||||
console.log("Top Performers");
|
||||
console.log("─────────────────────────────────────");
|
||||
for (const post of report.topPerformers.slice(0, 5)) {
|
||||
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
|
||||
console.log(`• ${title}`);
|
||||
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement | ${post.publishedDate}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (report.underperformers.length > 0) {
|
||||
console.log("Underperformers");
|
||||
console.log("─────────────────────────────────────");
|
||||
for (const post of report.underperformers.slice(0, 3)) {
|
||||
const title = post.title.length > 50 ? post.title.substring(0, 47) + "..." : post.title;
|
||||
console.log(`• ${title}`);
|
||||
console.log(` ${post.metrics.impressions.toLocaleString()} impressions | ${post.metrics.engagementRate.toFixed(2)}% engagement | ${post.publishedDate}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log("Trends");
|
||||
console.log("─────────────────────────────────────");
|
||||
console.log(`Impressions trend: ${report.trends.impressionsTrend.toUpperCase()} (${report.trends.percentChange.impressions > 0 ? "+" : ""}${report.trends.percentChange.impressions.toFixed(1)}%)`);
|
||||
console.log(`Engagement trend: ${report.trends.engagementTrend.toUpperCase()} (${report.trends.percentChange.engagement > 0 ? "+" : ""}${report.trends.percentChange.engagement.toFixed(1)}%)`);
|
||||
console.log(`Compared to: ${report.trends.comparedTo}`);
|
||||
console.log();
|
||||
|
||||
if (report.alerts.length > 0) {
|
||||
console.log("Alerts");
|
||||
console.log("─────────────────────────────────────");
|
||||
for (const alert of report.alerts) {
|
||||
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "ℹ️";
|
||||
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`Report saved to: weekly-reports/${week}.json`);
|
||||
} catch (err) {
|
||||
console.error(`Error generating report: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a string is a valid PostMetrics key
|
||||
*/
|
||||
function isPostMetric(value: string): value is keyof PostMetrics {
|
||||
const validMetrics: (keyof PostMetrics)[] = [
|
||||
"impressions",
|
||||
"reactions",
|
||||
"comments",
|
||||
"shares",
|
||||
"clicks",
|
||||
"engagementRate",
|
||||
];
|
||||
return validMetrics.includes(value as keyof PostMetrics);
|
||||
}
|
||||
|
||||
async function handleTrends(root: string, args: string[]) {
|
||||
const periodOption = parseOption(args, "--period") || "month";
|
||||
const metricOption = parseOption(args, "--metric") || "impressions";
|
||||
|
||||
const validPeriods = ["week", "month", "quarter", "all"];
|
||||
|
||||
if (!validPeriods.includes(periodOption)) {
|
||||
console.error(`Error: Invalid period "${periodOption}". Must be one of: ${validPeriods.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!isPostMetric(metricOption)) {
|
||||
const validMetrics: (keyof PostMetrics)[] = [
|
||||
"impressions",
|
||||
"reactions",
|
||||
"comments",
|
||||
"shares",
|
||||
"clicks",
|
||||
"engagementRate",
|
||||
];
|
||||
console.error(`Error: Invalid metric "${metricOption}". Must be one of: ${validMetrics.join(", ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const period = periodOption as "week" | "month" | "quarter" | "all";
|
||||
const metric = metricOption;
|
||||
|
||||
console.log(`Analyzing trends for ${metric} over ${period}...`);
|
||||
|
||||
try {
|
||||
const allPosts = loadAllPosts(root);
|
||||
|
||||
if (allPosts.length === 0) {
|
||||
console.error("Error: No posts found. Import some data first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Calculate date range based on period
|
||||
const now = new Date();
|
||||
let fromDate = new Date(0); // Beginning of time for "all"
|
||||
|
||||
if (period === "week") {
|
||||
fromDate = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
} else if (period === "month") {
|
||||
fromDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
} else if (period === "quarter") {
|
||||
fromDate = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const fromDateStr = fromDate.toISOString().split("T")[0];
|
||||
|
||||
// Filter posts by period
|
||||
const filteredPosts = allPosts.filter(
|
||||
(post) => post.publishedDate >= fromDateStr
|
||||
);
|
||||
|
||||
if (filteredPosts.length === 0) {
|
||||
console.error(`Error: No posts found in the ${period} period.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
const values = filteredPosts.map((post) => post.metrics[metric]);
|
||||
const avg = mean(values);
|
||||
const stdDev = standardDeviation(values);
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
|
||||
console.log("\nTrend Analysis");
|
||||
console.log("═════════════════════════════════════");
|
||||
console.log(`Metric: ${metric}`);
|
||||
console.log(`Period: ${period}`);
|
||||
console.log(`Posts analyzed: ${filteredPosts.length}`);
|
||||
console.log(`Date range: ${filteredPosts[filteredPosts.length - 1].publishedDate} to ${filteredPosts[0].publishedDate}`);
|
||||
console.log();
|
||||
|
||||
console.log("Statistics");
|
||||
console.log("─────────────────────────────────────");
|
||||
console.log(`Mean: ${avg.toFixed(2)}`);
|
||||
console.log(`Std deviation: ${stdDev.toFixed(2)}`);
|
||||
console.log(`Min: ${min.toFixed(2)}`);
|
||||
console.log(`Max: ${max.toFixed(2)}`);
|
||||
console.log();
|
||||
|
||||
// Generate alerts
|
||||
const alerts = detectAlerts(filteredPosts, metric);
|
||||
|
||||
if (alerts.length > 0) {
|
||||
console.log("Alerts");
|
||||
console.log("─────────────────────────────────────");
|
||||
for (const alert of alerts) {
|
||||
const icon = alert.severity === "critical" ? "🔴" : alert.severity === "warning" ? "⚠️" : "ℹ️";
|
||||
console.log(`${icon} [${alert.severity.toUpperCase()}] ${alert.message}`);
|
||||
}
|
||||
} else {
|
||||
console.log("No anomalies detected in this period.");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error analyzing trends: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = getAnalyticsRoot();
|
||||
ensureDirectories(root);
|
||||
|
||||
switch (command) {
|
||||
case "import":
|
||||
await handleImport(root, args);
|
||||
break;
|
||||
case "report":
|
||||
await handleReport(root, args);
|
||||
break;
|
||||
case "trends":
|
||||
await handleTrends(root, args);
|
||||
break;
|
||||
default:
|
||||
printUsage();
|
||||
process.exit(command ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err instanceof Error ? err.message : String(err));
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
export interface PostAnalytics {
|
||||
id: string; // Hash of title + date
|
||||
title: string; // First ~100 chars of post content
|
||||
publishedDate: string; // YYYY-MM-DD
|
||||
metrics: PostMetrics;
|
||||
importedAt: string; // ISO datetime
|
||||
exportSource: string; // Original CSV filename
|
||||
}
|
||||
|
||||
export interface PostMetrics {
|
||||
impressions: number;
|
||||
reactions: number;
|
||||
comments: number;
|
||||
shares: number;
|
||||
clicks: number;
|
||||
engagementRate: number; // (reactions+comments+shares+clicks)/impressions * 100
|
||||
}
|
||||
|
||||
export interface AnalyticsBatch {
|
||||
batchId: string; // UUID-like identifier
|
||||
importedAt: string; // ISO datetime
|
||||
exportFilename: string;
|
||||
dateRange: { from: string; to: string };
|
||||
postCount: number;
|
||||
posts: PostAnalytics[];
|
||||
}
|
||||
|
||||
export interface WeeklyReport {
|
||||
week: string; // ISO week e.g. "2026-W05"
|
||||
generatedAt: string;
|
||||
summary: {
|
||||
totalPosts: number;
|
||||
totalImpressions: number;
|
||||
totalReactions: number;
|
||||
totalComments: number;
|
||||
totalShares: number;
|
||||
totalClicks: number;
|
||||
avgEngagementRate: number;
|
||||
avgImpressionsPerPost: number;
|
||||
};
|
||||
topPerformers: PostAnalytics[];
|
||||
underperformers: PostAnalytics[];
|
||||
trends: {
|
||||
impressionsTrend: TrendDirection;
|
||||
engagementTrend: TrendDirection;
|
||||
comparedTo: string;
|
||||
percentChange: {
|
||||
impressions: number;
|
||||
engagement: number;
|
||||
};
|
||||
};
|
||||
alerts: Alert[];
|
||||
}
|
||||
|
||||
export type TrendDirection = "up" | "down" | "stable";
|
||||
|
||||
export interface Alert {
|
||||
type: "spike" | "drop" | "milestone";
|
||||
severity: "info" | "warning" | "critical";
|
||||
metric: string;
|
||||
message: string;
|
||||
postId?: string;
|
||||
value: number;
|
||||
baseline: number;
|
||||
deviations: number;
|
||||
}
|
||||
|
||||
export const ALERT_THRESHOLDS = {
|
||||
spike: 2.0,
|
||||
drop: -1.5,
|
||||
weeklyDropWarning: -30,
|
||||
weeklyDropCritical: -50,
|
||||
weeklySpikeInfo: 100,
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
import { parse } from "csv-parse/sync";
|
||||
import { readFileSync } from "node:fs";
|
||||
import type { PostAnalytics, AnalyticsBatch, PostMetrics } from "../models/types.js";
|
||||
|
||||
/**
|
||||
* Detects delimiter (comma vs semicolon) by checking first line
|
||||
*/
|
||||
function detectDelimiter(content: string): string {
|
||||
const firstLine = content.split("\n")[0];
|
||||
const commaCount = (firstLine.match(/,/g) || []).length;
|
||||
const semicolonCount = (firstLine.match(/;/g) || []).length;
|
||||
return semicolonCount > commaCount ? ";" : ",";
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds column value using fuzzy pattern matching
|
||||
*/
|
||||
function findColumn(record: Record<string, string>, patterns: string[]): string {
|
||||
const keys = Object.keys(record);
|
||||
for (const pattern of patterns) {
|
||||
const key = keys.find((k) =>
|
||||
k.toLowerCase().includes(pattern.toLowerCase())
|
||||
);
|
||||
if (key) {
|
||||
return record[key];
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses metric value, handling both US (4,523) and EU (4.523) thousand separators
|
||||
* Clamps negative values to 0
|
||||
*/
|
||||
function parseMetric(value: string): number {
|
||||
if (!value) return 0;
|
||||
// Remove quotes and trim
|
||||
const cleaned = value.replace(/"/g, "").trim();
|
||||
// Check if it looks like EU format (4.523) or US format (4,523)
|
||||
// EU format has dots as thousand separators, US has commas
|
||||
// If there's both comma and dot, the last one is decimal separator
|
||||
const lastComma = cleaned.lastIndexOf(",");
|
||||
const lastDot = cleaned.lastIndexOf(".");
|
||||
|
||||
let normalized = cleaned;
|
||||
if (lastComma > lastDot) {
|
||||
// US format: remove commas (thousand separator), keep dots
|
||||
normalized = cleaned.replace(/,/g, "");
|
||||
} else {
|
||||
// EU format: remove dots (thousand separator), replace comma with dot
|
||||
normalized = cleaned.replace(/\./g, "").replace(/,/g, ".");
|
||||
}
|
||||
|
||||
const parsed = parseFloat(normalized) || 0;
|
||||
|
||||
// Clamp negative values to 0
|
||||
return Math.max(0, parsed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes date to YYYY-MM-DD format
|
||||
* Handles: DD.MM.YYYY, MM/DD/YYYY, YYYY-MM-DD
|
||||
* Returns null if date is invalid
|
||||
*/
|
||||
function normalizeDate(dateStr: string): string | null {
|
||||
if (!dateStr) return null;
|
||||
const cleaned = dateStr.replace(/"/g, "").trim();
|
||||
|
||||
// Already in YYYY-MM-DD format
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(cleaned)) {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// DD.MM.YYYY format
|
||||
if (/^\d{2}\.\d{2}\.\d{4}$/.test(cleaned)) {
|
||||
const [day, month, year] = cleaned.split(".");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// MM/DD/YYYY format
|
||||
if (/^\d{2}\/\d{2}\/\d{4}$/.test(cleaned)) {
|
||||
const [month, day, year] = cleaned.split("/");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
// YYYY/MM/DD format
|
||||
if (/^\d{4}\/\d{2}\/\d{2}$/.test(cleaned)) {
|
||||
return cleaned.replace(/\//g, "-");
|
||||
}
|
||||
|
||||
// Invalid date format
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple string hash function for generating deterministic post IDs
|
||||
*/
|
||||
function simpleHash(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash).toString(36);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates deterministic post ID from title and date
|
||||
*/
|
||||
function generatePostId(title: string, date: string): string {
|
||||
return simpleHash(`${title}:${date}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates batch ID using timestamp
|
||||
*/
|
||||
function generateBatchId(): string {
|
||||
const now = new Date();
|
||||
const timestamp = now.getTime();
|
||||
return `batch-${timestamp}-${simpleHash(timestamp.toString())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses LinkedIn CSV export into structured AnalyticsBatch
|
||||
*/
|
||||
export function parseLinkedInCSV(
|
||||
filePath: string,
|
||||
filename: string
|
||||
): AnalyticsBatch {
|
||||
// Read file
|
||||
let content = readFileSync(filePath, "utf-8");
|
||||
|
||||
// Strip BOM if present
|
||||
if (content.charCodeAt(0) === 0xfeff) {
|
||||
content = content.slice(1);
|
||||
}
|
||||
|
||||
// Detect delimiter
|
||||
const delimiter = detectDelimiter(content);
|
||||
|
||||
// Parse CSV
|
||||
const records = parse(content, {
|
||||
columns: true,
|
||||
skip_empty_lines: true,
|
||||
delimiter,
|
||||
quote: '"',
|
||||
trim: true,
|
||||
}) as Record<string, string>[];
|
||||
|
||||
// Normalize records into PostAnalytics, skipping invalid records
|
||||
const posts: PostAnalytics[] = records
|
||||
.map((record, index) => {
|
||||
const title = findColumn(record, ["content", "title", "post"]);
|
||||
const dateStr = findColumn(record, ["date", "published", "posted"]);
|
||||
const date = normalizeDate(dateStr);
|
||||
|
||||
// Skip records with empty titles
|
||||
if (!title || title.trim() === "") {
|
||||
console.warn(`Warning: Skipping record at line ${index + 2}: empty title`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip records with invalid dates
|
||||
if (!date) {
|
||||
console.warn(`Warning: Skipping record at line ${index + 2}: invalid date "${dateStr}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const impressions = parseMetric(findColumn(record, ["impression", "view"]));
|
||||
const reactions = parseMetric(findColumn(record, ["reaction", "like"]));
|
||||
const comments = parseMetric(findColumn(record, ["comment"]));
|
||||
const shares = parseMetric(findColumn(record, ["share", "repost"]));
|
||||
const clicks = parseMetric(findColumn(record, ["click"]));
|
||||
|
||||
// Calculate engagement rate
|
||||
const totalEngagement = reactions + comments + shares + clicks;
|
||||
const engagementRate = impressions > 0
|
||||
? (totalEngagement / impressions) * 100
|
||||
: 0;
|
||||
|
||||
const metrics: PostMetrics = {
|
||||
impressions,
|
||||
reactions,
|
||||
comments,
|
||||
shares,
|
||||
clicks,
|
||||
engagementRate,
|
||||
};
|
||||
|
||||
return {
|
||||
id: generatePostId(title, date),
|
||||
title,
|
||||
publishedDate: date,
|
||||
metrics,
|
||||
importedAt: new Date().toISOString(),
|
||||
exportSource: filename,
|
||||
};
|
||||
})
|
||||
.filter((post): post is PostAnalytics => post !== null);
|
||||
|
||||
// Find date range
|
||||
const dates = posts.map((p) => p.publishedDate).filter((d) => d);
|
||||
const sortedDates = dates.sort();
|
||||
const dateRange = {
|
||||
from: sortedDates[0] || "",
|
||||
to: sortedDates[sortedDates.length - 1] || "",
|
||||
};
|
||||
|
||||
// Build AnalyticsBatch
|
||||
const batch: AnalyticsBatch = {
|
||||
batchId: generateBatchId(),
|
||||
importedAt: new Date().toISOString(),
|
||||
exportFilename: filename,
|
||||
dateRange,
|
||||
postCount: posts.length,
|
||||
posts,
|
||||
};
|
||||
|
||||
return batch;
|
||||
}
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
import type { PostAnalytics, WeeklyReport } from "../models/types.js";
|
||||
import { mean, trendDirection, percentChange } from "../utils/stats.js";
|
||||
import { detectAlerts, detectWeeklyAlerts } from "../utils/alerts.js";
|
||||
import { loadAllPosts, loadWeeklyReport, saveWeeklyReport } from "../utils/storage.js";
|
||||
|
||||
/**
|
||||
* Get current ISO week string (e.g., "2026-W05").
|
||||
* Uses ISO 8601 week date system where Monday is first day of week.
|
||||
*/
|
||||
export function getCurrentISOWeek(): string {
|
||||
return getISOWeek(new Date());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ISO week string for a specific date.
|
||||
* Format: "YYYY-WXX" where XX is zero-padded week number.
|
||||
*
|
||||
* ISO 8601 week date rules:
|
||||
* - Week starts on Monday
|
||||
* - Week 1 is the week with the first Thursday of the year
|
||||
* - Last week of year might extend into next year
|
||||
*/
|
||||
export function getISOWeek(date: Date): string {
|
||||
// Copy date to avoid mutating original
|
||||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||||
|
||||
// Set to nearest Thursday: current date + 4 - current day number
|
||||
// Make Sunday's day number 7
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
|
||||
// Get first day of year
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
|
||||
// Calculate full weeks to nearest Thursday
|
||||
const weekNo = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
|
||||
|
||||
// Return ISO week format
|
||||
const year = d.getUTCFullYear();
|
||||
const weekStr = weekNo.toString().padStart(2, '0');
|
||||
|
||||
return `${year}-W${weekStr}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter posts to a specific ISO week.
|
||||
* Posts are matched by converting their publishedDate to ISO week format.
|
||||
*/
|
||||
export function getPostsForWeek(posts: PostAnalytics[], week: string): PostAnalytics[] {
|
||||
return posts.filter(post => {
|
||||
const postDate = new Date(post.publishedDate);
|
||||
const postWeek = getISOWeek(postDate);
|
||||
return postWeek === week;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the ISO week string for the previous week.
|
||||
* Uses proper ISO week calculation to handle year boundaries correctly.
|
||||
*/
|
||||
function getPreviousWeek(week: string): string {
|
||||
// Parse week string (e.g., "2026-W05")
|
||||
const match = week.match(/^(\d{4})-W(\d{2})$/);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid week format: ${week}`);
|
||||
}
|
||||
|
||||
const year = parseInt(match[1]);
|
||||
const weekNum = parseInt(match[2]);
|
||||
|
||||
// ISO week 1 is the week containing January 4th
|
||||
// Find Thursday of the target ISO week
|
||||
const jan4 = new Date(Date.UTC(year, 0, 4));
|
||||
|
||||
// Find Monday of week 1 by going back from Jan 4 to Monday
|
||||
const jan4Day = jan4.getUTCDay() || 7; // Sunday = 7 in ISO
|
||||
const week1Monday = new Date(jan4.getTime() - (jan4Day - 1) * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Add (weekNum - 1) * 7 days to get Monday of target week
|
||||
const targetMonday = new Date(week1Monday.getTime() + (weekNum - 1) * 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Add 3 days to get Thursday of target week
|
||||
const targetThursday = new Date(targetMonday.getTime() + 3 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Subtract 7 days to get previous week's Thursday
|
||||
const previousThursday = new Date(targetThursday.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Use getISOWeek to get the correct ISO week string
|
||||
return getISOWeek(previousThursday);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a weekly report from imported analytics data.
|
||||
*
|
||||
* @param analyticsRoot - Root directory containing analytics data
|
||||
* @param week - ISO week string (e.g., "2026-W05"). If not provided, uses current week.
|
||||
* @returns WeeklyReport object
|
||||
*
|
||||
* Process:
|
||||
* 1. Load all posts from storage
|
||||
* 2. Filter posts for target week
|
||||
* 3. Calculate summary metrics
|
||||
* 4. Find top 3 performers and bottom 3 underperformers
|
||||
* 5. Calculate trends vs previous week
|
||||
* 6. Generate alerts
|
||||
* 7. Save and return report
|
||||
*
|
||||
* Edge cases:
|
||||
* - No posts for week → zeroed summary
|
||||
* - No previous week data → stable trends with 0% change
|
||||
* - Fewer than 3 posts → shorter top/bottom lists
|
||||
*/
|
||||
export function generateWeeklyReport(analyticsRoot: string, week?: string): WeeklyReport {
|
||||
// Determine target week
|
||||
const targetWeek = week || getCurrentISOWeek();
|
||||
|
||||
// Load all posts
|
||||
const allPosts = loadAllPosts(analyticsRoot);
|
||||
|
||||
// Filter posts for target week
|
||||
const weekPosts = getPostsForWeek(allPosts, targetWeek);
|
||||
|
||||
// Initialize report structure
|
||||
const report: WeeklyReport = {
|
||||
week: targetWeek,
|
||||
generatedAt: new Date().toISOString(),
|
||||
summary: {
|
||||
totalPosts: weekPosts.length,
|
||||
totalImpressions: 0,
|
||||
totalReactions: 0,
|
||||
totalComments: 0,
|
||||
totalShares: 0,
|
||||
totalClicks: 0,
|
||||
avgEngagementRate: 0,
|
||||
avgImpressionsPerPost: 0,
|
||||
},
|
||||
topPerformers: [],
|
||||
underperformers: [],
|
||||
trends: {
|
||||
impressionsTrend: "stable",
|
||||
engagementTrend: "stable",
|
||||
comparedTo: getPreviousWeek(targetWeek),
|
||||
percentChange: {
|
||||
impressions: 0,
|
||||
engagement: 0,
|
||||
},
|
||||
},
|
||||
alerts: [],
|
||||
};
|
||||
|
||||
// If no posts, return early with zeroed report
|
||||
if (weekPosts.length === 0) {
|
||||
return report;
|
||||
}
|
||||
|
||||
// Calculate summary metrics
|
||||
for (const post of weekPosts) {
|
||||
report.summary.totalImpressions += post.metrics.impressions;
|
||||
report.summary.totalReactions += post.metrics.reactions;
|
||||
report.summary.totalComments += post.metrics.comments;
|
||||
report.summary.totalShares += post.metrics.shares;
|
||||
report.summary.totalClicks += post.metrics.clicks;
|
||||
}
|
||||
|
||||
// Calculate averages
|
||||
const engagementRates = weekPosts.map(post => post.metrics.engagementRate);
|
||||
report.summary.avgEngagementRate = mean(engagementRates);
|
||||
report.summary.avgImpressionsPerPost = report.summary.totalImpressions / weekPosts.length;
|
||||
|
||||
// Find top 3 performers (highest engagement rate)
|
||||
const sortedByEngagement = [...weekPosts].sort(
|
||||
(a, b) => b.metrics.engagementRate - a.metrics.engagementRate
|
||||
);
|
||||
report.topPerformers = sortedByEngagement.slice(0, 3);
|
||||
|
||||
// Find bottom 3 underperformers (lowest engagement rate)
|
||||
report.underperformers = sortedByEngagement
|
||||
.slice()
|
||||
.reverse()
|
||||
.slice(0, 3);
|
||||
|
||||
// Calculate trends vs previous week
|
||||
const previousWeek = getPreviousWeek(targetWeek);
|
||||
const previousReport = loadWeeklyReport(analyticsRoot, previousWeek);
|
||||
|
||||
if (previousReport && previousReport.summary.totalPosts > 0) {
|
||||
// Calculate percent changes
|
||||
report.trends.percentChange.impressions = percentChange(
|
||||
report.summary.totalImpressions,
|
||||
previousReport.summary.totalImpressions
|
||||
);
|
||||
|
||||
report.trends.percentChange.engagement = percentChange(
|
||||
report.summary.avgEngagementRate,
|
||||
previousReport.summary.avgEngagementRate
|
||||
);
|
||||
|
||||
// Determine trend directions
|
||||
report.trends.impressionsTrend = trendDirection(
|
||||
report.summary.totalImpressions,
|
||||
previousReport.summary.totalImpressions
|
||||
);
|
||||
|
||||
report.trends.engagementTrend = trendDirection(
|
||||
report.summary.avgEngagementRate,
|
||||
previousReport.summary.avgEngagementRate
|
||||
);
|
||||
}
|
||||
|
||||
// Generate alerts
|
||||
const postAlerts = detectAlerts(weekPosts, "impressions");
|
||||
|
||||
let weeklyAlerts: typeof report.alerts = [];
|
||||
if (previousReport && previousReport.summary.totalPosts > 0) {
|
||||
weeklyAlerts = detectWeeklyAlerts(
|
||||
{
|
||||
impressions: report.summary.totalImpressions,
|
||||
engagementRate: report.summary.avgEngagementRate,
|
||||
},
|
||||
{
|
||||
impressions: previousReport.summary.totalImpressions,
|
||||
engagementRate: previousReport.summary.avgEngagementRate,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
report.alerts = [...weeklyAlerts, ...postAlerts];
|
||||
|
||||
// Save report
|
||||
saveWeeklyReport(analyticsRoot, report);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
|
@ -0,0 +1,162 @@
|
|||
import type {
|
||||
PostAnalytics,
|
||||
Alert,
|
||||
PostMetrics,
|
||||
} from "../models/types.js";
|
||||
import { ALERT_THRESHOLDS } from "../models/types.js";
|
||||
import {
|
||||
mean,
|
||||
deviationsFromMean,
|
||||
percentChange,
|
||||
} from "./stats.js";
|
||||
|
||||
/**
|
||||
* Analyze posts for spikes and drops based on standard deviation thresholds.
|
||||
* For each post, checks if its metric value deviates significantly from the mean.
|
||||
* Returns array of alerts sorted by severity (critical first).
|
||||
*/
|
||||
export function detectAlerts(
|
||||
posts: PostAnalytics[],
|
||||
metricKey: keyof PostMetrics = "impressions"
|
||||
): Alert[] {
|
||||
if (posts.length === 0) return [];
|
||||
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
// Extract metric values
|
||||
const values = posts.map((post) => post.metrics[metricKey]);
|
||||
const avg = mean(values);
|
||||
|
||||
// Check each post for significant deviations
|
||||
for (const post of posts) {
|
||||
const value = post.metrics[metricKey];
|
||||
const deviations = deviationsFromMean(value, values);
|
||||
|
||||
// Spike detection
|
||||
if (deviations > ALERT_THRESHOLDS.spike) {
|
||||
alerts.push({
|
||||
type: "spike",
|
||||
severity: "info",
|
||||
metric: metricKey,
|
||||
message: `Post "${post.title}" has unusually high ${metricKey}: ${value.toLocaleString()} (${deviations.toFixed(1)} std deviations above mean)`,
|
||||
postId: post.id,
|
||||
value,
|
||||
baseline: avg,
|
||||
deviations,
|
||||
});
|
||||
}
|
||||
|
||||
// Drop detection
|
||||
if (deviations < ALERT_THRESHOLDS.drop) {
|
||||
alerts.push({
|
||||
type: "drop",
|
||||
severity: "warning",
|
||||
metric: metricKey,
|
||||
message: `Post "${post.title}" has unusually low ${metricKey}: ${value.toLocaleString()} (${Math.abs(deviations).toFixed(1)} std deviations below mean)`,
|
||||
postId: post.id,
|
||||
value,
|
||||
baseline: avg,
|
||||
deviations,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by severity: critical > warning > info
|
||||
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
||||
alerts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare week-over-week metrics and generate alerts for significant changes.
|
||||
* Uses percentChange and ALERT_THRESHOLDS for weekly drops and spikes.
|
||||
*/
|
||||
export function detectWeeklyAlerts(
|
||||
currentWeekMetrics: { impressions: number; engagementRate: number },
|
||||
previousWeekMetrics: { impressions: number; engagementRate: number }
|
||||
): Alert[] {
|
||||
const alerts: Alert[] = [];
|
||||
|
||||
// Analyze impressions
|
||||
const impressionChange = percentChange(
|
||||
currentWeekMetrics.impressions,
|
||||
previousWeekMetrics.impressions
|
||||
);
|
||||
|
||||
if (impressionChange < ALERT_THRESHOLDS.weeklyDropCritical) {
|
||||
alerts.push({
|
||||
type: "drop",
|
||||
severity: "critical",
|
||||
metric: "impressions",
|
||||
message: `Critical drop in weekly impressions: ${impressionChange.toFixed(1)}% (from ${previousWeekMetrics.impressions.toLocaleString()} to ${currentWeekMetrics.impressions.toLocaleString()})`,
|
||||
value: currentWeekMetrics.impressions,
|
||||
baseline: previousWeekMetrics.impressions,
|
||||
deviations: impressionChange / 10, // Rough conversion to deviations
|
||||
});
|
||||
} else if (impressionChange < ALERT_THRESHOLDS.weeklyDropWarning) {
|
||||
alerts.push({
|
||||
type: "drop",
|
||||
severity: "warning",
|
||||
metric: "impressions",
|
||||
message: `Weekly impressions dropped by ${Math.abs(impressionChange).toFixed(1)}%: from ${previousWeekMetrics.impressions.toLocaleString()} to ${currentWeekMetrics.impressions.toLocaleString()}`,
|
||||
value: currentWeekMetrics.impressions,
|
||||
baseline: previousWeekMetrics.impressions,
|
||||
deviations: impressionChange / 10,
|
||||
});
|
||||
} else if (impressionChange > ALERT_THRESHOLDS.weeklySpikeInfo) {
|
||||
alerts.push({
|
||||
type: "spike",
|
||||
severity: "info",
|
||||
metric: "impressions",
|
||||
message: `Strong growth in weekly impressions: +${impressionChange.toFixed(1)}% (from ${previousWeekMetrics.impressions.toLocaleString()} to ${currentWeekMetrics.impressions.toLocaleString()})`,
|
||||
value: currentWeekMetrics.impressions,
|
||||
baseline: previousWeekMetrics.impressions,
|
||||
deviations: impressionChange / 10,
|
||||
});
|
||||
}
|
||||
|
||||
// Analyze engagement rate
|
||||
const engagementChange = percentChange(
|
||||
currentWeekMetrics.engagementRate,
|
||||
previousWeekMetrics.engagementRate
|
||||
);
|
||||
|
||||
if (engagementChange < ALERT_THRESHOLDS.weeklyDropCritical) {
|
||||
alerts.push({
|
||||
type: "drop",
|
||||
severity: "critical",
|
||||
metric: "engagementRate",
|
||||
message: `Critical drop in weekly engagement rate: ${engagementChange.toFixed(1)}% (from ${previousWeekMetrics.engagementRate.toFixed(2)}% to ${currentWeekMetrics.engagementRate.toFixed(2)}%)`,
|
||||
value: currentWeekMetrics.engagementRate,
|
||||
baseline: previousWeekMetrics.engagementRate,
|
||||
deviations: engagementChange / 10,
|
||||
});
|
||||
} else if (engagementChange < ALERT_THRESHOLDS.weeklyDropWarning) {
|
||||
alerts.push({
|
||||
type: "drop",
|
||||
severity: "warning",
|
||||
metric: "engagementRate",
|
||||
message: `Weekly engagement rate dropped by ${Math.abs(engagementChange).toFixed(1)}%: from ${previousWeekMetrics.engagementRate.toFixed(2)}% to ${currentWeekMetrics.engagementRate.toFixed(2)}%`,
|
||||
value: currentWeekMetrics.engagementRate,
|
||||
baseline: previousWeekMetrics.engagementRate,
|
||||
deviations: engagementChange / 10,
|
||||
});
|
||||
} else if (engagementChange > ALERT_THRESHOLDS.weeklySpikeInfo) {
|
||||
alerts.push({
|
||||
type: "spike",
|
||||
severity: "info",
|
||||
metric: "engagementRate",
|
||||
message: `Strong growth in weekly engagement rate: +${engagementChange.toFixed(1)}% (from ${previousWeekMetrics.engagementRate.toFixed(2)}% to ${currentWeekMetrics.engagementRate.toFixed(2)}%)`,
|
||||
value: currentWeekMetrics.engagementRate,
|
||||
baseline: previousWeekMetrics.engagementRate,
|
||||
deviations: engagementChange / 10,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by severity: critical > warning > info
|
||||
const severityOrder = { critical: 0, warning: 1, info: 2 };
|
||||
alerts.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
|
||||
|
||||
return alerts;
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import type { TrendDirection } from "../models/types.js";
|
||||
|
||||
/**
|
||||
* Calculate arithmetic mean of values.
|
||||
* Returns 0 for empty array.
|
||||
*/
|
||||
export function mean(values: number[]): number {
|
||||
if (values.length === 0) return 0;
|
||||
const sum = values.reduce((acc, val) => acc + val, 0);
|
||||
return sum / values.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate population standard deviation.
|
||||
* Returns 0 for empty or single-element array.
|
||||
*/
|
||||
export function standardDeviation(values: number[]): number {
|
||||
if (values.length <= 1) return 0;
|
||||
|
||||
const avg = mean(values);
|
||||
const squaredDiffs = values.map((val) => Math.pow(val - avg, 2));
|
||||
const variance = mean(squaredDiffs);
|
||||
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine trend direction based on percentage change.
|
||||
* Returns "up" if change > threshold, "down" if change < -threshold, "stable" otherwise.
|
||||
* Default threshold is 5%.
|
||||
*/
|
||||
export function trendDirection(
|
||||
current: number,
|
||||
previous: number,
|
||||
threshold: number = 5
|
||||
): TrendDirection {
|
||||
const change = percentChange(current, previous);
|
||||
|
||||
if (change > threshold) return "up";
|
||||
if (change < -threshold) return "down";
|
||||
return "stable";
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate percentage change between current and previous values.
|
||||
* Returns 0 if previous is 0.
|
||||
*/
|
||||
export function percentChange(current: number, previous: number): number {
|
||||
if (previous === 0) return 0;
|
||||
return ((current - previous) / previous) * 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate how many standard deviations a value is from the mean.
|
||||
* Returns 0 if standard deviation is 0.
|
||||
*/
|
||||
export function deviationsFromMean(value: number, values: number[]): number {
|
||||
const avg = mean(values);
|
||||
const stdDev = standardDeviation(values);
|
||||
|
||||
if (stdDev === 0) return 0;
|
||||
return (value - avg) / stdDev;
|
||||
}
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { join, resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { AnalyticsBatch, WeeklyReport, PostAnalytics } from "../models/types.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/**
|
||||
* Get the analytics root directory from environment or default location
|
||||
* Default is assets/analytics relative to the plugin root
|
||||
*/
|
||||
export function getAnalyticsRoot(): string {
|
||||
if (process.env.ANALYTICS_ROOT) {
|
||||
return resolve(process.env.ANALYTICS_ROOT);
|
||||
}
|
||||
|
||||
// Build output is at: scripts/analytics/build/utils/storage.js
|
||||
// Plugin root is 4 levels up: ../../../../
|
||||
// Then assets/analytics from there
|
||||
const pluginRoot = resolve(__dirname, "../../../../");
|
||||
return join(pluginRoot, "assets", "analytics");
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure required subdirectories exist under analytics root
|
||||
*/
|
||||
export function ensureDirectories(root: string): void {
|
||||
const directories = ["exports", "posts", "weekly-reports"];
|
||||
|
||||
if (!existsSync(root)) {
|
||||
mkdirSync(root, { recursive: true });
|
||||
}
|
||||
|
||||
for (const dir of directories) {
|
||||
const path = join(root, dir);
|
||||
if (!existsSync(path)) {
|
||||
mkdirSync(path, { recursive: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all CSV export files in the exports directory
|
||||
*/
|
||||
export function listExports(root: string): string[] {
|
||||
const exportsDir = join(root, "exports");
|
||||
|
||||
if (!existsSync(exportsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return readdirSync(exportsDir)
|
||||
.filter(file => file.endsWith(".csv"))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize date string to only allow YYYY-MM-DD format
|
||||
*/
|
||||
function sanitizeDate(date: string): string {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
throw new Error(`Invalid date format: ${date}. Expected YYYY-MM-DD`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize ID string to only allow alphanumeric and hyphens
|
||||
*/
|
||||
function sanitizeId(id: string): string {
|
||||
if (!/^[a-zA-Z0-9-]+$/.test(id)) {
|
||||
throw new Error(`Invalid ID format: ${id}. Only alphanumeric and hyphens allowed`);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that the resolved path is within the expected directory
|
||||
*/
|
||||
function verifyPathWithinDirectory(filepath: string, expectedDir: string): void {
|
||||
const resolvedPath = resolve(filepath);
|
||||
const resolvedDir = resolve(expectedDir);
|
||||
|
||||
if (!resolvedPath.startsWith(resolvedDir + "/") && resolvedPath !== resolvedDir) {
|
||||
throw new Error(`Path traversal detected: ${filepath} is not within ${expectedDir}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an analytics batch to disk
|
||||
* Returns the filename that was created
|
||||
*/
|
||||
export function saveBatch(root: string, batch: AnalyticsBatch): string {
|
||||
ensureDirectories(root);
|
||||
|
||||
const postsDir = join(root, "posts");
|
||||
|
||||
// Sanitize inputs to prevent path traversal
|
||||
const date = sanitizeDate(batch.dateRange.from);
|
||||
const shortId = sanitizeId(batch.batchId.substring(0, 8));
|
||||
const filename = `${date}-${shortId}.json`;
|
||||
const filepath = join(postsDir, filename);
|
||||
|
||||
// Verify the resolved filepath is within postsDir
|
||||
verifyPathWithinDirectory(filepath, postsDir);
|
||||
|
||||
writeFileSync(filepath, JSON.stringify(batch, null, 2), "utf-8");
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all analytics batches from disk
|
||||
* Returns batches sorted by importedAt timestamp
|
||||
*/
|
||||
export function loadAllBatches(root: string): AnalyticsBatch[] {
|
||||
const postsDir = join(root, "posts");
|
||||
|
||||
if (!existsSync(postsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const batches: AnalyticsBatch[] = [];
|
||||
|
||||
for (const file of readdirSync(postsDir)) {
|
||||
if (!file.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filepath = join(postsDir, file);
|
||||
const content = readFileSync(filepath, "utf-8");
|
||||
try {
|
||||
const batch = JSON.parse(content) as AnalyticsBatch;
|
||||
batches.push(batch);
|
||||
} catch {
|
||||
// Skip corrupt batch file
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return batches.sort((a, b) =>
|
||||
a.importedAt.localeCompare(b.importedAt)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all posts from all batches, deduplicated by post ID
|
||||
* Latest import wins. Sorted by publishedDate descending.
|
||||
*/
|
||||
export function loadAllPosts(root: string): PostAnalytics[] {
|
||||
const batches = loadAllBatches(root);
|
||||
|
||||
// Use Map to deduplicate - key is post ID, value is { post, importedAt }
|
||||
const postMap = new Map<string, { post: PostAnalytics; importedAt: string }>();
|
||||
|
||||
for (const batch of batches) {
|
||||
for (const post of batch.posts) {
|
||||
const existing = postMap.get(post.id);
|
||||
|
||||
// Keep post with latest importedAt timestamp
|
||||
if (!existing || batch.importedAt > existing.importedAt) {
|
||||
postMap.set(post.id, {
|
||||
post,
|
||||
importedAt: batch.importedAt
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract posts and sort by publishedDate descending
|
||||
const posts = Array.from(postMap.values()).map(({ post }) => post);
|
||||
|
||||
return posts.sort((a, b) =>
|
||||
b.publishedDate.localeCompare(a.publishedDate)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize week string to only allow ISO week format (YYYY-WXX)
|
||||
*/
|
||||
function sanitizeWeek(week: string): string {
|
||||
if (!/^\d{4}-W\d{2}$/.test(week)) {
|
||||
throw new Error(`Invalid week format: ${week}. Expected YYYY-WXX`);
|
||||
}
|
||||
return week;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a weekly report to disk
|
||||
* Returns the filename that was created
|
||||
*/
|
||||
export function saveWeeklyReport(root: string, report: WeeklyReport): string {
|
||||
ensureDirectories(root);
|
||||
|
||||
const reportsDir = join(root, "weekly-reports");
|
||||
|
||||
// Sanitize week to prevent path traversal
|
||||
const week = sanitizeWeek(report.week);
|
||||
const filename = `${week}.json`;
|
||||
const filepath = join(reportsDir, filename);
|
||||
|
||||
// Verify the resolved filepath is within reportsDir
|
||||
verifyPathWithinDirectory(filepath, reportsDir);
|
||||
|
||||
writeFileSync(filepath, JSON.stringify(report, null, 2), "utf-8");
|
||||
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a specific weekly report by week identifier
|
||||
* Returns null if not found
|
||||
*/
|
||||
export function loadWeeklyReport(root: string, week: string): WeeklyReport | null {
|
||||
week = sanitizeWeek(week);
|
||||
const reportsDir = join(root, "weekly-reports");
|
||||
const filepath = join(reportsDir, `${week}.json`);
|
||||
|
||||
if (!existsSync(filepath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = readFileSync(filepath, "utf-8");
|
||||
return JSON.parse(content) as WeeklyReport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all weekly reports from disk
|
||||
* Returns reports sorted by week descending (newest first)
|
||||
*/
|
||||
export function loadAllWeeklyReports(root: string): WeeklyReport[] {
|
||||
const reportsDir = join(root, "weekly-reports");
|
||||
|
||||
if (!existsSync(reportsDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const reports: WeeklyReport[] = [];
|
||||
|
||||
for (const file of readdirSync(reportsDir)) {
|
||||
if (!file.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const filepath = join(reportsDir, file);
|
||||
const content = readFileSync(filepath, "utf-8");
|
||||
const report = JSON.parse(content) as WeeklyReport;
|
||||
reports.push(report);
|
||||
}
|
||||
|
||||
return reports.sort((a, b) =>
|
||||
b.week.localeCompare(a.week)
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { detectAlerts, detectWeeklyAlerts } from "../src/utils/alerts.js";
|
||||
import type { PostAnalytics } from "../src/models/types.js";
|
||||
|
||||
/**
|
||||
* Helper function to create PostAnalytics with default values.
|
||||
*/
|
||||
function makePost(overrides: Partial<PostAnalytics> = {}): PostAnalytics {
|
||||
return {
|
||||
id: `post-${Math.random().toString(36).substr(2, 9)}`,
|
||||
title: "Test Post",
|
||||
publishedDate: "2026-01-15",
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 100,
|
||||
engagementRate: 5.0,
|
||||
},
|
||||
importedAt: new Date().toISOString(),
|
||||
exportSource: "LinkedIn",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("alerts", () => {
|
||||
describe("detectAlerts", () => {
|
||||
test("should find spike posts", () => {
|
||||
// Create posts with one outlier high value
|
||||
// Need value that's > 2.0 standard deviations from mean (not >=)
|
||||
// Using more base values to create a scenario where outlier exceeds threshold
|
||||
const posts = [
|
||||
makePost({ id: "1", title: "Normal Post 1", metrics: { impressions: 1000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "2", title: "Normal Post 2", metrics: { impressions: 1200, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "3", title: "Normal Post 3", metrics: { impressions: 1100, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "4", title: "Normal Post 4", metrics: { impressions: 900, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "5", title: "Normal Post 5", metrics: { impressions: 1050, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "6", title: "Viral Post", metrics: { impressions: 10000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
];
|
||||
|
||||
const alerts = detectAlerts(posts, "impressions");
|
||||
|
||||
assert.ok(alerts.length > 0, "Should detect at least one alert");
|
||||
const spikeAlert = alerts.find(a => a.type === "spike");
|
||||
assert.ok(spikeAlert, "Should have a spike alert");
|
||||
assert.equal(spikeAlert.severity, "info");
|
||||
assert.equal(spikeAlert.postId, "6");
|
||||
assert.ok(spikeAlert.message.includes("Viral Post"));
|
||||
});
|
||||
|
||||
test("should find drop posts", () => {
|
||||
// Create posts with one outlier low value
|
||||
const posts = [
|
||||
makePost({ id: "1", title: "Normal Post 1", metrics: { impressions: 10000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "2", title: "Normal Post 2", metrics: { impressions: 10000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "3", title: "Normal Post 3", metrics: { impressions: 10000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "4", title: "Low Reach Post", metrics: { impressions: 100, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
];
|
||||
|
||||
const alerts = detectAlerts(posts, "impressions");
|
||||
|
||||
assert.equal(alerts.length, 1);
|
||||
assert.equal(alerts[0].type, "drop");
|
||||
assert.equal(alerts[0].severity, "warning");
|
||||
assert.equal(alerts[0].postId, "4");
|
||||
assert.ok(alerts[0].message.includes("Low Reach Post"));
|
||||
});
|
||||
|
||||
test("should return empty for uniform data", () => {
|
||||
const posts = [
|
||||
makePost({ id: "1", metrics: { impressions: 5000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "2", metrics: { impressions: 5000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "3", metrics: { impressions: 5000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
];
|
||||
|
||||
const alerts = detectAlerts(posts, "impressions");
|
||||
|
||||
assert.equal(alerts.length, 0);
|
||||
});
|
||||
|
||||
test("should handle empty posts array", () => {
|
||||
const alerts = detectAlerts([]);
|
||||
assert.equal(alerts.length, 0);
|
||||
});
|
||||
|
||||
test("should sort alerts by severity", () => {
|
||||
// Create scenario with multiple alerts of different severities
|
||||
// For this, we'd need to manually create alerts with different severities
|
||||
// Since detectAlerts only produces "info" spikes and "warning" drops,
|
||||
// let's just verify the sorting works with what we have
|
||||
const posts = [
|
||||
makePost({ id: "1", metrics: { impressions: 5000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "2", metrics: { impressions: 5000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }),
|
||||
makePost({ id: "3", metrics: { impressions: 100, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }), // Drop
|
||||
makePost({ id: "4", metrics: { impressions: 50000, reactions: 50, comments: 10, shares: 5, clicks: 100, engagementRate: 5.0 } }), // Spike
|
||||
];
|
||||
|
||||
const alerts = detectAlerts(posts, "impressions");
|
||||
|
||||
// Should have drop (warning) first, then spike (info)
|
||||
if (alerts.length > 1) {
|
||||
assert.equal(alerts[0].severity, "warning");
|
||||
assert.equal(alerts[1].severity, "info");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectWeeklyAlerts", () => {
|
||||
test("should detect critical drop in impressions", () => {
|
||||
const current = { impressions: 1000, engagementRate: 5.0 };
|
||||
const previous = { impressions: 3000, engagementRate: 5.0 }; // -66.7% drop
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const impressionAlerts = alerts.filter((a) => a.metric === "impressions");
|
||||
assert.ok(impressionAlerts.length > 0);
|
||||
assert.equal(impressionAlerts[0].severity, "critical");
|
||||
assert.equal(impressionAlerts[0].type, "drop");
|
||||
});
|
||||
|
||||
test("should detect warning drop in impressions", () => {
|
||||
const current = { impressions: 6000, engagementRate: 5.0 };
|
||||
const previous = { impressions: 10000, engagementRate: 5.0 }; // -40% drop
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const impressionAlerts = alerts.filter((a) => a.metric === "impressions");
|
||||
assert.ok(impressionAlerts.length > 0);
|
||||
assert.equal(impressionAlerts[0].severity, "warning");
|
||||
assert.equal(impressionAlerts[0].type, "drop");
|
||||
});
|
||||
|
||||
test("should detect spike in impressions", () => {
|
||||
const current = { impressions: 25000, engagementRate: 5.0 };
|
||||
const previous = { impressions: 10000, engagementRate: 5.0 }; // +150% increase
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const impressionAlerts = alerts.filter((a) => a.metric === "impressions");
|
||||
assert.ok(impressionAlerts.length > 0);
|
||||
assert.equal(impressionAlerts[0].severity, "info");
|
||||
assert.equal(impressionAlerts[0].type, "spike");
|
||||
});
|
||||
|
||||
test("should detect critical drop in engagement rate", () => {
|
||||
const current = { impressions: 10000, engagementRate: 2.0 };
|
||||
const previous = { impressions: 10000, engagementRate: 6.0 }; // -66.7% drop
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const engagementAlerts = alerts.filter((a) => a.metric === "engagementRate");
|
||||
assert.ok(engagementAlerts.length > 0);
|
||||
assert.equal(engagementAlerts[0].severity, "critical");
|
||||
assert.equal(engagementAlerts[0].type, "drop");
|
||||
});
|
||||
|
||||
test("should detect warning drop in engagement rate", () => {
|
||||
const current = { impressions: 10000, engagementRate: 3.0 };
|
||||
const previous = { impressions: 10000, engagementRate: 5.0 }; // -40% drop
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const engagementAlerts = alerts.filter((a) => a.metric === "engagementRate");
|
||||
assert.ok(engagementAlerts.length > 0);
|
||||
assert.equal(engagementAlerts[0].severity, "warning");
|
||||
assert.equal(engagementAlerts[0].type, "drop");
|
||||
});
|
||||
|
||||
test("should detect spike in engagement rate", () => {
|
||||
const current = { impressions: 10000, engagementRate: 12.0 };
|
||||
const previous = { impressions: 10000, engagementRate: 5.0 }; // +140% increase
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
const engagementAlerts = alerts.filter((a) => a.metric === "engagementRate");
|
||||
assert.ok(engagementAlerts.length > 0);
|
||||
assert.equal(engagementAlerts[0].severity, "info");
|
||||
assert.equal(engagementAlerts[0].type, "spike");
|
||||
});
|
||||
|
||||
test("should return empty for stable metrics", () => {
|
||||
const current = { impressions: 10000, engagementRate: 5.0 };
|
||||
const previous = { impressions: 10200, engagementRate: 5.1 }; // Small changes
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
assert.equal(alerts.length, 0);
|
||||
});
|
||||
|
||||
test("should handle multiple alerts and sort by severity", () => {
|
||||
const current = { impressions: 1000, engagementRate: 2.0 };
|
||||
const previous = { impressions: 3000, engagementRate: 6.0 }; // Both critical drops
|
||||
|
||||
const alerts = detectWeeklyAlerts(current, previous);
|
||||
|
||||
assert.ok(alerts.length >= 2);
|
||||
// All should be critical
|
||||
alerts.forEach((alert) => {
|
||||
assert.equal(alert.severity, "critical");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseLinkedInCSV } from "../src/parsers/csv-parser.js";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const fixturesDir = join(__dirname, "fixtures");
|
||||
|
||||
describe("CSV Parser", () => {
|
||||
it("should parse standard CSV export", () => {
|
||||
const filePath = join(fixturesDir, "sample-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "sample-export.csv");
|
||||
|
||||
assert.equal(batch.postCount, 8, "Should have 8 posts");
|
||||
assert.equal(batch.posts.length, 8, "Posts array should have 8 items");
|
||||
assert.equal(batch.exportFilename, "sample-export.csv");
|
||||
assert.ok(batch.batchId, "Should have a batchId");
|
||||
assert.ok(batch.importedAt, "Should have importedAt timestamp");
|
||||
|
||||
// Check first post
|
||||
const firstPost = batch.posts[0];
|
||||
assert.ok(firstPost.id, "Post should have an ID");
|
||||
assert.ok(
|
||||
firstPost.title.includes("uncomfortable truth"),
|
||||
"Title should match"
|
||||
);
|
||||
assert.equal(firstPost.publishedDate, "2026-01-28");
|
||||
assert.equal(firstPost.metrics.impressions, 4523);
|
||||
assert.equal(firstPost.metrics.reactions, 87);
|
||||
assert.equal(firstPost.metrics.comments, 23);
|
||||
assert.equal(firstPost.metrics.shares, 12);
|
||||
assert.equal(firstPost.metrics.clicks, 156);
|
||||
assert.ok(firstPost.metrics.engagementRate > 0, "Should have engagement rate");
|
||||
});
|
||||
|
||||
it("should handle European format", () => {
|
||||
const filePath = join(fixturesDir, "european-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "european-export.csv");
|
||||
|
||||
assert.equal(batch.postCount, 2, "Should have 2 posts");
|
||||
|
||||
// Check that European number format is parsed correctly
|
||||
const firstPost = batch.posts[0];
|
||||
assert.equal(firstPost.metrics.impressions, 4523, "Should parse 4.523 as 4523");
|
||||
assert.equal(firstPost.publishedDate, "2026-01-28", "Should normalize date from DD.MM.YYYY");
|
||||
|
||||
const secondPost = batch.posts[1];
|
||||
assert.equal(secondPost.metrics.impressions, 2891, "Should parse 2.891 as 2891");
|
||||
assert.equal(secondPost.publishedDate, "2026-01-26", "Should normalize date from DD.MM.YYYY");
|
||||
});
|
||||
|
||||
it("should handle empty CSV", () => {
|
||||
const filePath = join(fixturesDir, "empty-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "empty-export.csv");
|
||||
|
||||
assert.equal(batch.postCount, 0, "Should have 0 posts");
|
||||
assert.equal(batch.posts.length, 0, "Posts array should be empty");
|
||||
assert.equal(batch.dateRange.from, "", "Date range from should be empty");
|
||||
assert.equal(batch.dateRange.to, "", "Date range to should be empty");
|
||||
});
|
||||
|
||||
it("should handle BOM", () => {
|
||||
const filePath = join(fixturesDir, "bom-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "bom-export.csv");
|
||||
|
||||
assert.equal(batch.postCount, 8, "Should parse BOM file correctly");
|
||||
assert.ok(
|
||||
batch.posts[0].title.includes("uncomfortable truth"),
|
||||
"Should parse first post correctly despite BOM"
|
||||
);
|
||||
});
|
||||
|
||||
it("should calculate engagement rate", () => {
|
||||
const filePath = join(fixturesDir, "sample-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "sample-export.csv");
|
||||
|
||||
const firstPost = batch.posts[0];
|
||||
// (87+23+12+156)/4523 * 100 = 6.14...
|
||||
const expectedRate = ((87 + 23 + 12 + 156) / 4523) * 100;
|
||||
assert.ok(
|
||||
Math.abs(firstPost.metrics.engagementRate - expectedRate) < 0.01,
|
||||
`Engagement rate should be ~${expectedRate}, got ${firstPost.metrics.engagementRate}`
|
||||
);
|
||||
});
|
||||
|
||||
it("should generate deterministic post IDs", () => {
|
||||
const filePath = join(fixturesDir, "sample-export.csv");
|
||||
const batch1 = parseLinkedInCSV(filePath, "sample-export.csv");
|
||||
const batch2 = parseLinkedInCSV(filePath, "sample-export.csv");
|
||||
|
||||
// Same post should have same ID
|
||||
assert.equal(
|
||||
batch1.posts[0].id,
|
||||
batch2.posts[0].id,
|
||||
"Same post should generate same ID"
|
||||
);
|
||||
|
||||
// Different posts should have different IDs
|
||||
assert.notEqual(
|
||||
batch1.posts[0].id,
|
||||
batch1.posts[1].id,
|
||||
"Different posts should have different IDs"
|
||||
);
|
||||
});
|
||||
|
||||
it("should normalize dates to YYYY-MM-DD", () => {
|
||||
const filePath = join(fixturesDir, "sample-export.csv");
|
||||
const batch = parseLinkedInCSV(filePath, "sample-export.csv");
|
||||
|
||||
// All dates should be in YYYY-MM-DD format
|
||||
batch.posts.forEach((post) => {
|
||||
assert.match(
|
||||
post.publishedDate,
|
||||
/^\d{4}-\d{2}-\d{2}$/,
|
||||
`Date ${post.publishedDate} should be in YYYY-MM-DD format`
|
||||
);
|
||||
});
|
||||
|
||||
// Check date range
|
||||
assert.equal(batch.dateRange.from, "2026-01-13", "Date range from should be earliest date");
|
||||
assert.equal(batch.dateRange.to, "2026-01-28", "Date range to should be latest date");
|
||||
});
|
||||
});
|
||||
9
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/bom-export.csv
vendored
Normal file
9
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/bom-export.csv
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks"
|
||||
"The uncomfortable truth about AI governance in public sector. Most organizations are focused on...",2026-01-28,4523,87,23,12,156
|
||||
"3 frameworks I use daily for evaluating AI tools before recommending them to government...",2026-01-26,2891,54,18,8,94
|
||||
"Why 80% of AI projects fail in public sector (and what the 20% do differently)...",2026-01-24,8712,192,45,31,287
|
||||
"Just spent 3 hours debugging a Copilot Studio flow. The issue? A single missing...",2026-01-22,1543,32,41,5,67
|
||||
"Hot take: The best AI strategy for 2026 isn't about AI at all. It's about...",2026-01-20,6234,143,67,28,198
|
||||
"I asked 50 government employees about their biggest AI challenge. The #1 answer surprised...",2026-01-17,5891,128,89,19,234
|
||||
"Unpopular opinion: Low-code/no-code platforms are actually harder than they look. Here's why...",2026-01-15,3456,76,34,11,123
|
||||
"The meeting that changed how I think about AI adoption in large organizations...",2026-01-13,2198,48,22,7,89
|
||||
|
1
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/empty-export.csv
vendored
Normal file
1
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/empty-export.csv
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks"
|
||||
|
3
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/european-export.csv
vendored
Normal file
3
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/european-export.csv
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
"Content";"Date";"Impressions";"Reactions";"Comments";"Shares";"Clicks"
|
||||
"The uncomfortable truth about AI governance...";"28.01.2026";"4.523";"87";"23";"12";"156"
|
||||
"3 frameworks I use daily...";"26.01.2026";"2.891";"54";"18";"8";"94"
|
||||
|
9
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/sample-export.csv
vendored
Normal file
9
plugins/linkedin-thought-leadership/scripts/analytics/tests/fixtures/sample-export.csv
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"Content","Date","Impressions","Reactions","Comments","Shares","Clicks"
|
||||
"The uncomfortable truth about AI governance in public sector. Most organizations are focused on...",2026-01-28,4523,87,23,12,156
|
||||
"3 frameworks I use daily for evaluating AI tools before recommending them to government...",2026-01-26,2891,54,18,8,94
|
||||
"Why 80% of AI projects fail in public sector (and what the 20% do differently)...",2026-01-24,8712,192,45,31,287
|
||||
"Just spent 3 hours debugging a Copilot Studio flow. The issue? A single missing...",2026-01-22,1543,32,41,5,67
|
||||
"Hot take: The best AI strategy for 2026 isn't about AI at all. It's about...",2026-01-20,6234,143,67,28,198
|
||||
"I asked 50 government employees about their biggest AI challenge. The #1 answer surprised...",2026-01-17,5891,128,89,19,234
|
||||
"Unpopular opinion: Low-code/no-code platforms are actually harder than they look. Here's why...",2026-01-15,3456,76,34,11,123
|
||||
"The meeting that changed how I think about AI adoption in large organizations...",2026-01-13,2198,48,22,7,89
|
||||
|
|
|
@ -0,0 +1,139 @@
|
|||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mean,
|
||||
standardDeviation,
|
||||
trendDirection,
|
||||
percentChange,
|
||||
deviationsFromMean,
|
||||
} from "../src/utils/stats.js";
|
||||
|
||||
describe("stats", () => {
|
||||
describe("mean", () => {
|
||||
test("should return mean of values", () => {
|
||||
const result = mean([10, 20, 30]);
|
||||
assert.equal(result, 20);
|
||||
});
|
||||
|
||||
test("should return 0 for empty array", () => {
|
||||
const result = mean([]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should handle single value", () => {
|
||||
const result = mean([42]);
|
||||
assert.equal(result, 42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("standardDeviation", () => {
|
||||
test("should calculate correctly for known values", () => {
|
||||
// For [2, 4, 4, 4, 5, 5, 7, 9]:
|
||||
// Mean = 5
|
||||
// Variance = ((2-5)^2 + (4-5)^2 + (4-5)^2 + (4-5)^2 + (5-5)^2 + (5-5)^2 + (7-5)^2 + (9-5)^2) / 8
|
||||
// Variance = (9 + 1 + 1 + 1 + 0 + 0 + 4 + 16) / 8 = 32 / 8 = 4
|
||||
// StdDev = 2
|
||||
const result = standardDeviation([2, 4, 4, 4, 5, 5, 7, 9]);
|
||||
assert.equal(result, 2);
|
||||
});
|
||||
|
||||
test("should return 0 for single value", () => {
|
||||
const result = standardDeviation([5]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should return 0 for empty array", () => {
|
||||
const result = standardDeviation([]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should handle uniform values", () => {
|
||||
const result = standardDeviation([5, 5, 5, 5]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trendDirection", () => {
|
||||
test("should detect up trend", () => {
|
||||
const result = trendDirection(110, 100);
|
||||
assert.equal(result, "up");
|
||||
});
|
||||
|
||||
test("should detect down trend", () => {
|
||||
const result = trendDirection(90, 100);
|
||||
assert.equal(result, "down");
|
||||
});
|
||||
|
||||
test("should detect stable trend", () => {
|
||||
const result = trendDirection(103, 100);
|
||||
assert.equal(result, "stable");
|
||||
});
|
||||
|
||||
test("should use custom threshold", () => {
|
||||
const result = trendDirection(103, 100, 10);
|
||||
assert.equal(result, "stable");
|
||||
});
|
||||
|
||||
test("should detect up with custom threshold", () => {
|
||||
const result = trendDirection(112, 100, 10);
|
||||
assert.equal(result, "up");
|
||||
});
|
||||
});
|
||||
|
||||
describe("percentChange", () => {
|
||||
test("should calculate positive change correctly", () => {
|
||||
const result = percentChange(110, 100);
|
||||
assert.equal(result, 10);
|
||||
});
|
||||
|
||||
test("should calculate negative change correctly", () => {
|
||||
const result = percentChange(90, 100);
|
||||
assert.equal(result, -10);
|
||||
});
|
||||
|
||||
test("should handle zero previous value", () => {
|
||||
const result = percentChange(100, 0);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should handle zero current value", () => {
|
||||
const result = percentChange(0, 100);
|
||||
assert.equal(result, -100);
|
||||
});
|
||||
|
||||
test("should handle no change", () => {
|
||||
const result = percentChange(100, 100);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deviationsFromMean", () => {
|
||||
test("should calculate correctly for value above mean", () => {
|
||||
// Mean of [10, 20, 30] = 20
|
||||
// StdDev = sqrt(((10-20)^2 + (20-20)^2 + (30-20)^2) / 3) = sqrt((100 + 0 + 100) / 3) = sqrt(66.67) ≈ 8.165
|
||||
// Deviations for 30 = (30 - 20) / 8.165 ≈ 1.225
|
||||
const result = deviationsFromMean(30, [10, 20, 30]);
|
||||
assert.ok(Math.abs(result - 1.225) < 0.01);
|
||||
});
|
||||
|
||||
test("should calculate correctly for value below mean", () => {
|
||||
const result = deviationsFromMean(10, [10, 20, 30]);
|
||||
assert.ok(Math.abs(result + 1.225) < 0.01); // Negative deviation
|
||||
});
|
||||
|
||||
test("should return 0 for uniform data", () => {
|
||||
const result = deviationsFromMean(5, [5, 5, 5]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should return 0 for single value", () => {
|
||||
const result = deviationsFromMean(5, [5]);
|
||||
assert.equal(result, 0);
|
||||
});
|
||||
|
||||
test("should calculate for value at mean", () => {
|
||||
const result = deviationsFromMean(20, [10, 20, 30]);
|
||||
assert.ok(Math.abs(result) < 0.01);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,440 @@
|
|||
import { describe, test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, writeFileSync, existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
ensureDirectories,
|
||||
saveBatch,
|
||||
loadAllBatches,
|
||||
loadAllPosts,
|
||||
listExports,
|
||||
saveWeeklyReport,
|
||||
loadWeeklyReport,
|
||||
loadAllWeeklyReports,
|
||||
} from "../src/utils/storage.js";
|
||||
import type { AnalyticsBatch, PostAnalytics, WeeklyReport } from "../src/models/types.js";
|
||||
|
||||
// Helper function to create test post data
|
||||
function createTestPost(overrides?: Partial<PostAnalytics>): PostAnalytics {
|
||||
return {
|
||||
id: overrides?.id || "test-post-1",
|
||||
title: overrides?.title || "Test post content",
|
||||
publishedDate: overrides?.publishedDate || "2026-01-15",
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 8.5,
|
||||
...(overrides?.metrics || {}),
|
||||
},
|
||||
importedAt: overrides?.importedAt || "2026-01-20T10:00:00Z",
|
||||
exportSource: overrides?.exportSource || "test-export.csv",
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create test batch data
|
||||
function createTestBatch(overrides?: Partial<AnalyticsBatch>): AnalyticsBatch {
|
||||
const posts = overrides?.posts || [createTestPost()];
|
||||
return {
|
||||
batchId: overrides?.batchId || "12345678-1234-1234-1234-123456789abc",
|
||||
importedAt: overrides?.importedAt || "2026-01-20T10:00:00Z",
|
||||
exportFilename: overrides?.exportFilename || "test-export.csv",
|
||||
dateRange: overrides?.dateRange || { from: "2026-01-15", to: "2026-01-20" },
|
||||
postCount: posts.length,
|
||||
posts,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create test weekly report
|
||||
function createTestWeeklyReport(overrides?: Partial<WeeklyReport>): WeeklyReport {
|
||||
return {
|
||||
week: overrides?.week || "2026-W03",
|
||||
generatedAt: overrides?.generatedAt || "2026-01-20T10:00:00Z",
|
||||
summary: {
|
||||
totalPosts: 5,
|
||||
totalImpressions: 5000,
|
||||
totalReactions: 250,
|
||||
totalComments: 50,
|
||||
totalShares: 25,
|
||||
totalClicks: 100,
|
||||
avgEngagementRate: 8.5,
|
||||
avgImpressionsPerPost: 1000,
|
||||
...(overrides?.summary || {}),
|
||||
},
|
||||
topPerformers: overrides?.topPerformers || [],
|
||||
underperformers: overrides?.underperformers || [],
|
||||
trends: {
|
||||
impressionsTrend: "up",
|
||||
engagementTrend: "stable",
|
||||
comparedTo: "2026-W02",
|
||||
percentChange: {
|
||||
impressions: 10,
|
||||
engagement: 2,
|
||||
},
|
||||
...(overrides?.trends || {}),
|
||||
},
|
||||
alerts: overrides?.alerts || [],
|
||||
};
|
||||
}
|
||||
|
||||
describe("storage", () => {
|
||||
let tempDir: string;
|
||||
|
||||
// Create temp directory before each test
|
||||
function setupTempDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "analytics-test-"));
|
||||
}
|
||||
|
||||
// Clean up temp directory after each test
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("ensureDirectories", () => {
|
||||
test("should create directories", () => {
|
||||
tempDir = setupTempDir();
|
||||
ensureDirectories(tempDir);
|
||||
|
||||
assert.ok(existsSync(join(tempDir, "exports")));
|
||||
assert.ok(existsSync(join(tempDir, "posts")));
|
||||
assert.ok(existsSync(join(tempDir, "weekly-reports")));
|
||||
});
|
||||
|
||||
test("should not fail if directories already exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
ensureDirectories(tempDir);
|
||||
// Call again - should not throw
|
||||
ensureDirectories(tempDir);
|
||||
|
||||
assert.ok(existsSync(join(tempDir, "exports")));
|
||||
assert.ok(existsSync(join(tempDir, "posts")));
|
||||
assert.ok(existsSync(join(tempDir, "weekly-reports")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveBatch", () => {
|
||||
test("should write JSON file", () => {
|
||||
tempDir = setupTempDir();
|
||||
const batch = createTestBatch();
|
||||
|
||||
const filename = saveBatch(tempDir, batch);
|
||||
|
||||
assert.ok(filename.startsWith("2026-01-15-"));
|
||||
assert.ok(filename.endsWith(".json"));
|
||||
|
||||
const filepath = join(tempDir, "posts", filename);
|
||||
assert.ok(existsSync(filepath));
|
||||
|
||||
// Verify content
|
||||
const loadedBatches = loadAllBatches(tempDir);
|
||||
assert.equal(loadedBatches.length, 1);
|
||||
assert.equal(loadedBatches[0].batchId, batch.batchId);
|
||||
});
|
||||
|
||||
test("should create directories if they don't exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
const batch = createTestBatch();
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
assert.ok(existsSync(join(tempDir, "posts")));
|
||||
});
|
||||
|
||||
test("should use short batch ID in filename", () => {
|
||||
tempDir = setupTempDir();
|
||||
const batch = createTestBatch({
|
||||
batchId: "abcdef12-3456-7890-abcd-ef1234567890",
|
||||
dateRange: { from: "2026-01-15", to: "2026-01-20" },
|
||||
});
|
||||
|
||||
const filename = saveBatch(tempDir, batch);
|
||||
|
||||
assert.ok(filename.startsWith("2026-01-15-abcdef12"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAllBatches", () => {
|
||||
test("should load saved batches", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const batch1 = createTestBatch({
|
||||
batchId: "batch1",
|
||||
importedAt: "2026-01-20T10:00:00Z",
|
||||
});
|
||||
const batch2 = createTestBatch({
|
||||
batchId: "batch2",
|
||||
importedAt: "2026-01-21T10:00:00Z",
|
||||
dateRange: { from: "2026-01-21", to: "2026-01-21" },
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch1);
|
||||
saveBatch(tempDir, batch2);
|
||||
|
||||
const batches = loadAllBatches(tempDir);
|
||||
|
||||
assert.equal(batches.length, 2);
|
||||
assert.equal(batches[0].batchId, "batch1");
|
||||
assert.equal(batches[1].batchId, "batch2");
|
||||
});
|
||||
|
||||
test("should return empty array if posts directory doesn't exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const batches = loadAllBatches(tempDir);
|
||||
|
||||
assert.deepEqual(batches, []);
|
||||
});
|
||||
|
||||
test("should sort by importedAt timestamp", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const batch1 = createTestBatch({
|
||||
batchId: "batch1",
|
||||
importedAt: "2026-01-22T10:00:00Z",
|
||||
dateRange: { from: "2026-01-22", to: "2026-01-22" },
|
||||
});
|
||||
const batch2 = createTestBatch({
|
||||
batchId: "batch2",
|
||||
importedAt: "2026-01-20T10:00:00Z",
|
||||
});
|
||||
const batch3 = createTestBatch({
|
||||
batchId: "batch3",
|
||||
importedAt: "2026-01-21T10:00:00Z",
|
||||
dateRange: { from: "2026-01-21", to: "2026-01-21" },
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch1);
|
||||
saveBatch(tempDir, batch2);
|
||||
saveBatch(tempDir, batch3);
|
||||
|
||||
const batches = loadAllBatches(tempDir);
|
||||
|
||||
assert.equal(batches.length, 3);
|
||||
assert.equal(batches[0].batchId, "batch2"); // Earliest
|
||||
assert.equal(batches[1].batchId, "batch3");
|
||||
assert.equal(batches[2].batchId, "batch1"); // Latest
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAllPosts", () => {
|
||||
test("should deduplicate by post ID", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const post1 = createTestPost({
|
||||
id: "post1",
|
||||
title: "Old version",
|
||||
publishedDate: "2026-01-15",
|
||||
});
|
||||
const post1Updated = createTestPost({
|
||||
id: "post1",
|
||||
title: "New version",
|
||||
publishedDate: "2026-01-15",
|
||||
});
|
||||
const post2 = createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-16",
|
||||
});
|
||||
|
||||
const batch1 = createTestBatch({
|
||||
batchId: "batch1",
|
||||
importedAt: "2026-01-20T10:00:00Z",
|
||||
posts: [post1, post2],
|
||||
});
|
||||
const batch2 = createTestBatch({
|
||||
batchId: "batch2",
|
||||
importedAt: "2026-01-21T10:00:00Z",
|
||||
dateRange: { from: "2026-01-21", to: "2026-01-21" },
|
||||
posts: [post1Updated], // Later import of post1
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch1);
|
||||
saveBatch(tempDir, batch2);
|
||||
|
||||
const posts = loadAllPosts(tempDir);
|
||||
|
||||
assert.equal(posts.length, 2);
|
||||
// Should have the updated version of post1
|
||||
const foundPost1 = posts.find(p => p.id === "post1");
|
||||
assert.equal(foundPost1?.title, "New version");
|
||||
});
|
||||
|
||||
test("should sort by publishedDate descending", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const post1 = createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-15",
|
||||
});
|
||||
const post2 = createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-17",
|
||||
});
|
||||
const post3 = createTestPost({
|
||||
id: "post3",
|
||||
publishedDate: "2026-01-16",
|
||||
});
|
||||
|
||||
const batch = createTestBatch({
|
||||
posts: [post1, post2, post3],
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const posts = loadAllPosts(tempDir);
|
||||
|
||||
assert.equal(posts.length, 3);
|
||||
assert.equal(posts[0].id, "post2"); // 2026-01-17
|
||||
assert.equal(posts[1].id, "post3"); // 2026-01-16
|
||||
assert.equal(posts[2].id, "post1"); // 2026-01-15
|
||||
});
|
||||
|
||||
test("should return empty array if no batches exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts = loadAllPosts(tempDir);
|
||||
|
||||
assert.deepEqual(posts, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listExports", () => {
|
||||
test("should list CSV files", () => {
|
||||
tempDir = setupTempDir();
|
||||
ensureDirectories(tempDir);
|
||||
|
||||
const exportsDir = join(tempDir, "exports");
|
||||
writeFileSync(join(exportsDir, "export1.csv"), "data");
|
||||
writeFileSync(join(exportsDir, "export2.csv"), "data");
|
||||
writeFileSync(join(exportsDir, "other.txt"), "data"); // Non-CSV
|
||||
|
||||
const exports = listExports(tempDir);
|
||||
|
||||
assert.equal(exports.length, 2);
|
||||
assert.ok(exports.includes("export1.csv"));
|
||||
assert.ok(exports.includes("export2.csv"));
|
||||
});
|
||||
|
||||
test("should return empty for missing directory", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const exports = listExports(tempDir);
|
||||
|
||||
assert.deepEqual(exports, []);
|
||||
});
|
||||
|
||||
test("should return sorted list", () => {
|
||||
tempDir = setupTempDir();
|
||||
ensureDirectories(tempDir);
|
||||
|
||||
const exportsDir = join(tempDir, "exports");
|
||||
writeFileSync(join(exportsDir, "c.csv"), "data");
|
||||
writeFileSync(join(exportsDir, "a.csv"), "data");
|
||||
writeFileSync(join(exportsDir, "b.csv"), "data");
|
||||
|
||||
const exports = listExports(tempDir);
|
||||
|
||||
assert.deepEqual(exports, ["a.csv", "b.csv", "c.csv"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveWeeklyReport", () => {
|
||||
test("should write report JSON", () => {
|
||||
tempDir = setupTempDir();
|
||||
const report = createTestWeeklyReport({ week: "2026-W03" });
|
||||
|
||||
const filename = saveWeeklyReport(tempDir, report);
|
||||
|
||||
assert.equal(filename, "2026-W03.json");
|
||||
|
||||
const filepath = join(tempDir, "weekly-reports", filename);
|
||||
assert.ok(existsSync(filepath));
|
||||
|
||||
// Verify content
|
||||
const loaded = loadWeeklyReport(tempDir, "2026-W03");
|
||||
assert.ok(loaded);
|
||||
assert.equal(loaded.week, "2026-W03");
|
||||
assert.equal(loaded.summary.totalPosts, 5);
|
||||
});
|
||||
|
||||
test("should create directories if they don't exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
const report = createTestWeeklyReport();
|
||||
|
||||
saveWeeklyReport(tempDir, report);
|
||||
|
||||
assert.ok(existsSync(join(tempDir, "weekly-reports")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadWeeklyReport", () => {
|
||||
test("should return null for missing report", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const report = loadWeeklyReport(tempDir, "2026-W99");
|
||||
|
||||
assert.equal(report, null);
|
||||
});
|
||||
|
||||
test("should load existing report", () => {
|
||||
tempDir = setupTempDir();
|
||||
const report = createTestWeeklyReport({ week: "2026-W03" });
|
||||
|
||||
saveWeeklyReport(tempDir, report);
|
||||
|
||||
const loaded = loadWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
assert.ok(loaded);
|
||||
assert.equal(loaded.week, "2026-W03");
|
||||
assert.equal(loaded.summary.totalPosts, 5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAllWeeklyReports", () => {
|
||||
test("should load all reports sorted", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const report1 = createTestWeeklyReport({ week: "2026-W03" });
|
||||
const report2 = createTestWeeklyReport({ week: "2026-W01" });
|
||||
const report3 = createTestWeeklyReport({ week: "2026-W05" });
|
||||
|
||||
saveWeeklyReport(tempDir, report1);
|
||||
saveWeeklyReport(tempDir, report2);
|
||||
saveWeeklyReport(tempDir, report3);
|
||||
|
||||
const reports = loadAllWeeklyReports(tempDir);
|
||||
|
||||
assert.equal(reports.length, 3);
|
||||
assert.equal(reports[0].week, "2026-W05"); // Newest first
|
||||
assert.equal(reports[1].week, "2026-W03");
|
||||
assert.equal(reports[2].week, "2026-W01");
|
||||
});
|
||||
|
||||
test("should return empty array if directory doesn't exist", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const reports = loadAllWeeklyReports(tempDir);
|
||||
|
||||
assert.deepEqual(reports, []);
|
||||
});
|
||||
|
||||
test("should ignore non-JSON files", () => {
|
||||
tempDir = setupTempDir();
|
||||
ensureDirectories(tempDir);
|
||||
|
||||
const reportsDir = join(tempDir, "weekly-reports");
|
||||
const report = createTestWeeklyReport({ week: "2026-W03" });
|
||||
saveWeeklyReport(tempDir, report);
|
||||
writeFileSync(join(reportsDir, "readme.txt"), "data");
|
||||
|
||||
const reports = loadAllWeeklyReports(tempDir);
|
||||
|
||||
assert.equal(reports.length, 1);
|
||||
assert.equal(reports[0].week, "2026-W03");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,570 @@
|
|||
import { describe, test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync, rmSync, existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
getISOWeek,
|
||||
getCurrentISOWeek,
|
||||
getPostsForWeek,
|
||||
generateWeeklyReport,
|
||||
} from "../src/reports/weekly.js";
|
||||
import { saveBatch, saveWeeklyReport } from "../src/utils/storage.js";
|
||||
import type { PostAnalytics, AnalyticsBatch } from "../src/models/types.js";
|
||||
|
||||
// Helper function to create test post data
|
||||
function createTestPost(overrides?: Partial<PostAnalytics>): PostAnalytics {
|
||||
return {
|
||||
id: overrides?.id || "test-post-1",
|
||||
title: overrides?.title || "Test post content",
|
||||
publishedDate: overrides?.publishedDate || "2026-01-15",
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 8.5,
|
||||
...(overrides?.metrics || {}),
|
||||
},
|
||||
importedAt: overrides?.importedAt || "2026-01-20T10:00:00Z",
|
||||
exportSource: overrides?.exportSource || "test-export.csv",
|
||||
};
|
||||
}
|
||||
|
||||
// Helper function to create test batch data
|
||||
function createTestBatch(overrides?: Partial<AnalyticsBatch>): AnalyticsBatch {
|
||||
const posts = overrides?.posts || [createTestPost()];
|
||||
return {
|
||||
batchId: overrides?.batchId || "12345678-1234-1234-1234-123456789abc",
|
||||
importedAt: overrides?.importedAt || "2026-01-20T10:00:00Z",
|
||||
exportFilename: overrides?.exportFilename || "test-export.csv",
|
||||
dateRange: overrides?.dateRange || { from: "2026-01-15", to: "2026-01-20" },
|
||||
postCount: posts.length,
|
||||
posts,
|
||||
};
|
||||
}
|
||||
|
||||
describe("weekly", () => {
|
||||
let tempDir: string;
|
||||
|
||||
// Create temp directory before each test
|
||||
function setupTempDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "analytics-test-"));
|
||||
}
|
||||
|
||||
// Clean up temp directory after each test
|
||||
afterEach(() => {
|
||||
if (tempDir && existsSync(tempDir)) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("getISOWeek", () => {
|
||||
test("should return correct ISO week for 2026-01-01", () => {
|
||||
// 2026-01-01 is Thursday, should be week 1 of 2026
|
||||
const result = getISOWeek(new Date("2026-01-01"));
|
||||
assert.equal(result, "2026-W01");
|
||||
});
|
||||
|
||||
test("should return correct ISO week for 2025-12-29", () => {
|
||||
// 2025-12-29 is Monday, first day of ISO week 1, 2026
|
||||
const result = getISOWeek(new Date("2025-12-29"));
|
||||
assert.equal(result, "2026-W01");
|
||||
});
|
||||
|
||||
test("should return correct ISO week for 2025-12-28", () => {
|
||||
// 2025-12-28 is Sunday, last day of 2025 week 52
|
||||
const result = getISOWeek(new Date("2025-12-28"));
|
||||
assert.equal(result, "2025-W52");
|
||||
});
|
||||
|
||||
test("should handle year boundaries - early January", () => {
|
||||
// 2025-01-01 is Wednesday, should be in 2025-W01
|
||||
const result = getISOWeek(new Date("2025-01-01"));
|
||||
assert.equal(result, "2025-W01");
|
||||
});
|
||||
|
||||
test("should handle year boundaries - late December", () => {
|
||||
// 2024-12-30 is Monday, should be in 2025-W01
|
||||
const result = getISOWeek(new Date("2024-12-30"));
|
||||
assert.equal(result, "2025-W01");
|
||||
});
|
||||
|
||||
test("should handle mid-year dates", () => {
|
||||
// 2026-06-15 is Monday
|
||||
const result = getISOWeek(new Date("2026-06-15"));
|
||||
assert.equal(result, "2026-W25");
|
||||
});
|
||||
|
||||
test("should handle leap year", () => {
|
||||
// 2024 is a leap year, Feb 29 should be in week 9
|
||||
const result = getISOWeek(new Date("2024-02-29"));
|
||||
assert.equal(result, "2024-W09");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getCurrentISOWeek", () => {
|
||||
test("should return a valid ISO week format", () => {
|
||||
const result = getCurrentISOWeek();
|
||||
|
||||
// Should match YYYY-WXX format
|
||||
assert.match(result, /^\d{4}-W\d{2}$/);
|
||||
});
|
||||
|
||||
test("should return a week in reasonable range", () => {
|
||||
const result = getCurrentISOWeek();
|
||||
const year = parseInt(result.split("-")[0]);
|
||||
const week = parseInt(result.split("-W")[1]);
|
||||
|
||||
// Year should be current or adjacent
|
||||
const currentYear = new Date().getFullYear();
|
||||
assert.ok(year >= currentYear - 1 && year <= currentYear + 1);
|
||||
|
||||
// Week should be 1-53
|
||||
assert.ok(week >= 1 && week <= 53);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPostsForWeek", () => {
|
||||
test("should filter posts to correct week", () => {
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-05", // 2026-W02
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post3",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post4",
|
||||
publishedDate: "2026-01-19", // 2026-W04
|
||||
}),
|
||||
];
|
||||
|
||||
const week3Posts = getPostsForWeek(posts, "2026-W03");
|
||||
|
||||
assert.equal(week3Posts.length, 2);
|
||||
assert.ok(week3Posts.some(p => p.id === "post2"));
|
||||
assert.ok(week3Posts.some(p => p.id === "post3"));
|
||||
});
|
||||
|
||||
test("should return empty for weeks with no posts", () => {
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-05", // 2026-W02
|
||||
}),
|
||||
];
|
||||
|
||||
const result = getPostsForWeek(posts, "2026-W03");
|
||||
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test("should handle empty posts array", () => {
|
||||
const result = getPostsForWeek([], "2026-W03");
|
||||
|
||||
assert.deepEqual(result, []);
|
||||
});
|
||||
|
||||
test("should handle posts across year boundary", () => {
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2025-12-29", // 2026-W01 (Monday)
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-01", // 2026-W01 (Thursday)
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post3",
|
||||
publishedDate: "2025-12-28", // 2025-W52 (Sunday)
|
||||
}),
|
||||
];
|
||||
|
||||
const week1Posts = getPostsForWeek(posts, "2026-W01");
|
||||
|
||||
assert.equal(week1Posts.length, 2);
|
||||
assert.ok(week1Posts.some(p => p.id === "post1"));
|
||||
assert.ok(week1Posts.some(p => p.id === "post2"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateWeeklyReport", () => {
|
||||
test("should handle no posts", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
assert.equal(report.week, "2026-W03");
|
||||
assert.equal(report.summary.totalPosts, 0);
|
||||
assert.equal(report.summary.totalImpressions, 0);
|
||||
assert.equal(report.summary.avgEngagementRate, 0);
|
||||
assert.deepEqual(report.topPerformers, []);
|
||||
assert.deepEqual(report.underperformers, []);
|
||||
});
|
||||
|
||||
test("should calculate correct summary metrics", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 8.5,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 2000,
|
||||
reactions: 100,
|
||||
comments: 20,
|
||||
shares: 10,
|
||||
clicks: 40,
|
||||
engagementRate: 8.5,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post3",
|
||||
publishedDate: "2026-01-14", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1500,
|
||||
reactions: 75,
|
||||
comments: 15,
|
||||
shares: 7,
|
||||
clicks: 30,
|
||||
engagementRate: 8.47,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const batch = createTestBatch({
|
||||
dateRange: { from: "2026-01-12", to: "2026-01-14" },
|
||||
posts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
assert.equal(report.week, "2026-W03");
|
||||
assert.equal(report.summary.totalPosts, 3);
|
||||
assert.equal(report.summary.totalImpressions, 4500);
|
||||
assert.equal(report.summary.totalReactions, 225);
|
||||
assert.equal(report.summary.totalComments, 45);
|
||||
assert.equal(report.summary.totalShares, 22);
|
||||
assert.equal(report.summary.totalClicks, 90);
|
||||
assert.equal(report.summary.avgImpressionsPerPost, 1500);
|
||||
|
||||
// Average engagement rate: (8.5 + 8.5 + 8.47) / 3 ≈ 8.49
|
||||
assert.ok(Math.abs(report.summary.avgEngagementRate - 8.49) < 0.01);
|
||||
});
|
||||
|
||||
test("should identify top performers and underperformers", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "high1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 10.0,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "high2",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 9.0,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "medium",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 5.0,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "low1",
|
||||
publishedDate: "2026-01-14", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 3.0,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "low2",
|
||||
publishedDate: "2026-01-14", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 2.0,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const batch = createTestBatch({
|
||||
dateRange: { from: "2026-01-12", to: "2026-01-14" },
|
||||
posts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
// Top 3 performers (highest engagement)
|
||||
assert.equal(report.topPerformers.length, 3);
|
||||
assert.equal(report.topPerformers[0].id, "high1"); // 10.0
|
||||
assert.equal(report.topPerformers[1].id, "high2"); // 9.0
|
||||
assert.equal(report.topPerformers[2].id, "medium"); // 5.0
|
||||
|
||||
// Bottom 3 underperformers (lowest engagement)
|
||||
assert.equal(report.underperformers.length, 3);
|
||||
assert.equal(report.underperformers[0].id, "low2"); // 2.0
|
||||
assert.equal(report.underperformers[1].id, "low1"); // 3.0
|
||||
assert.equal(report.underperformers[2].id, "medium"); // 5.0
|
||||
});
|
||||
|
||||
test("should handle fewer than 3 posts", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 10.0,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "post2",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 5.0,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const batch = createTestBatch({
|
||||
dateRange: { from: "2026-01-12", to: "2026-01-13" },
|
||||
posts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
// Should have 2 top performers
|
||||
assert.equal(report.topPerformers.length, 2);
|
||||
// Should have 2 underperformers (same posts, reversed)
|
||||
assert.equal(report.underperformers.length, 2);
|
||||
});
|
||||
|
||||
test("should calculate trends when previous week data exists", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
// Create previous week data
|
||||
const prevWeekPosts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "prev1",
|
||||
publishedDate: "2026-01-05", // 2026-W02
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 8.5,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "prev2",
|
||||
publishedDate: "2026-01-06", // 2026-W02
|
||||
metrics: {
|
||||
impressions: 1000,
|
||||
reactions: 50,
|
||||
comments: 10,
|
||||
shares: 5,
|
||||
clicks: 20,
|
||||
engagementRate: 8.5,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const prevBatch = createTestBatch({
|
||||
dateRange: { from: "2026-01-05", to: "2026-01-06" },
|
||||
posts: prevWeekPosts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, prevBatch);
|
||||
|
||||
// Generate previous week report
|
||||
generateWeeklyReport(tempDir, "2026-W02");
|
||||
|
||||
// Create current week data with higher metrics
|
||||
const currentWeekPosts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "curr1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1500,
|
||||
reactions: 75,
|
||||
comments: 15,
|
||||
shares: 7,
|
||||
clicks: 30,
|
||||
engagementRate: 8.47,
|
||||
},
|
||||
}),
|
||||
createTestPost({
|
||||
id: "curr2",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
metrics: {
|
||||
impressions: 1500,
|
||||
reactions: 75,
|
||||
comments: 15,
|
||||
shares: 7,
|
||||
clicks: 30,
|
||||
engagementRate: 8.47,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const currentBatch = createTestBatch({
|
||||
dateRange: { from: "2026-01-12", to: "2026-01-13" },
|
||||
posts: currentWeekPosts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, currentBatch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
// Current impressions: 3000, Previous: 2000 → 50% increase
|
||||
assert.equal(report.trends.comparedTo, "2026-W02");
|
||||
assert.equal(report.trends.impressionsTrend, "up");
|
||||
assert.equal(report.trends.percentChange.impressions, 50);
|
||||
|
||||
// Engagement rate essentially the same
|
||||
assert.equal(report.trends.engagementTrend, "stable");
|
||||
});
|
||||
|
||||
test("should default to stable trends when no previous week data", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "post1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
}),
|
||||
];
|
||||
|
||||
const batch = createTestBatch({
|
||||
dateRange: { from: "2026-01-12", to: "2026-01-12" },
|
||||
posts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
assert.equal(report.trends.impressionsTrend, "stable");
|
||||
assert.equal(report.trends.engagementTrend, "stable");
|
||||
assert.equal(report.trends.percentChange.impressions, 0);
|
||||
assert.equal(report.trends.percentChange.engagement, 0);
|
||||
});
|
||||
|
||||
test("should filter posts correctly for target week", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const posts: PostAnalytics[] = [
|
||||
createTestPost({
|
||||
id: "w02-post",
|
||||
publishedDate: "2026-01-05", // 2026-W02
|
||||
}),
|
||||
createTestPost({
|
||||
id: "w03-post1",
|
||||
publishedDate: "2026-01-12", // 2026-W03
|
||||
}),
|
||||
createTestPost({
|
||||
id: "w03-post2",
|
||||
publishedDate: "2026-01-13", // 2026-W03
|
||||
}),
|
||||
createTestPost({
|
||||
id: "w04-post",
|
||||
publishedDate: "2026-01-19", // 2026-W04
|
||||
}),
|
||||
];
|
||||
|
||||
const batch = createTestBatch({
|
||||
dateRange: { from: "2026-01-05", to: "2026-01-19" },
|
||||
posts,
|
||||
});
|
||||
|
||||
saveBatch(tempDir, batch);
|
||||
|
||||
const report = generateWeeklyReport(tempDir, "2026-W03");
|
||||
|
||||
// Should only include W03 posts
|
||||
assert.equal(report.summary.totalPosts, 2);
|
||||
assert.equal(report.topPerformers.length, 2);
|
||||
assert.ok(report.topPerformers.some(p => p.id === "w03-post1"));
|
||||
assert.ok(report.topPerformers.some(p => p.id === "w03-post2"));
|
||||
});
|
||||
|
||||
test("should use current week if week parameter not provided", () => {
|
||||
tempDir = setupTempDir();
|
||||
|
||||
const report = generateWeeklyReport(tempDir);
|
||||
|
||||
// Should match current ISO week format
|
||||
assert.match(report.week, /^\d{4}-W\d{2}$/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "build", "tests"]
|
||||
}
|
||||
258
plugins/linkedin-thought-leadership/scripts/test-runner.sh
Executable file
258
plugins/linkedin-thought-leadership/scripts/test-runner.sh
Executable file
|
|
@ -0,0 +1,258 @@
|
|||
#!/bin/bash
|
||||
# LinkedIn Thought Leadership Plugin — Structure Validator
|
||||
# Validates file existence, frontmatter format, and router completeness
|
||||
# Usage: bash scripts/test-runner.sh
|
||||
|
||||
set -e
|
||||
|
||||
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
WARN=0
|
||||
|
||||
# Color output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[0;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
pass() { echo -e "${GREEN}✓${NC} $1"; PASS=$((PASS + 1)); }
|
||||
fail() { echo -e "${RED}✗${NC} $1"; FAIL=$((FAIL + 1)); }
|
||||
warn() { echo -e "${YELLOW}⚠${NC} $1"; WARN=$((WARN + 1)); }
|
||||
|
||||
echo "================================================"
|
||||
echo "LinkedIn Thought Leadership Plugin — Structure Validator"
|
||||
echo "Plugin root: $PLUGIN_ROOT"
|
||||
echo "================================================"
|
||||
echo ""
|
||||
|
||||
# --- Section 1: Core Files ---
|
||||
echo "--- Core Files ---"
|
||||
|
||||
for f in ".claude-plugin/plugin.json" "CLAUDE.md" "CHANGELOG.md" "docs/DEVELOPMENT-LOG.md" "README.md" "config/REMEMBER.template.md"; do
|
||||
if [ -f "$PLUGIN_ROOT/$f" ]; then
|
||||
pass "$f exists"
|
||||
else
|
||||
fail "$f MISSING"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 2: Agent Files ---
|
||||
echo "--- Agent Files ---"
|
||||
|
||||
EXPECTED_AGENTS=(
|
||||
"engagement-coach" "content-optimizer" "strategy-advisor" "analytics-interpreter"
|
||||
"content-planner" "content-tracker" "performance-reporter" "network-builder"
|
||||
"content-repurposer" "comment-strategist" "trend-spotter" "voice-trainer"
|
||||
"differentiation-checker" "post-feedback-monitor" "personalization-scorer"
|
||||
)
|
||||
|
||||
for agent in "${EXPECTED_AGENTS[@]}"; do
|
||||
f="agents/${agent}.md"
|
||||
if [ -f "$PLUGIN_ROOT/$f" ]; then
|
||||
# Check for YAML frontmatter
|
||||
if head -1 "$PLUGIN_ROOT/$f" | grep -q "^---"; then
|
||||
# Check for required fields
|
||||
if grep -q "^name:" "$PLUGIN_ROOT/$f" && grep -q "^model:" "$PLUGIN_ROOT/$f" && grep -q "^color:" "$PLUGIN_ROOT/$f"; then
|
||||
pass "$f (frontmatter OK)"
|
||||
else
|
||||
warn "$f (missing frontmatter fields)"
|
||||
fi
|
||||
else
|
||||
fail "$f (no YAML frontmatter)"
|
||||
fi
|
||||
else
|
||||
fail "$f MISSING"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 3: Command Files ---
|
||||
echo "--- Command Files ---"
|
||||
|
||||
EXPECTED_COMMANDS=(
|
||||
"linkedin" "linkedin:setup" "linkedin:post" "linkedin:quick" "linkedin:templates"
|
||||
"linkedin:pipeline" "linkedin:batch" "linkedin:analyze" "linkedin:audit"
|
||||
"linkedin:import" "linkedin:report" "linkedin:strategy" "linkedin:authority"
|
||||
"linkedin:competitive" "linkedin:monetize" "linkedin:profile"
|
||||
"linkedin:collab" "linkedin:speaking" "linkedin:multiplatform"
|
||||
"linkedin:ab-test"
|
||||
)
|
||||
|
||||
for cmd in "${EXPECTED_COMMANDS[@]}"; do
|
||||
f="commands/${cmd}.md"
|
||||
if [ -f "$PLUGIN_ROOT/$f" ]; then
|
||||
if head -1 "$PLUGIN_ROOT/$f" | grep -q "^---"; then
|
||||
if grep -q "^name:" "$PLUGIN_ROOT/$f" && grep -q "^description:" "$PLUGIN_ROOT/$f"; then
|
||||
pass "$f (frontmatter OK)"
|
||||
else
|
||||
warn "$f (missing frontmatter fields)"
|
||||
fi
|
||||
else
|
||||
fail "$f (no YAML frontmatter)"
|
||||
fi
|
||||
else
|
||||
fail "$f MISSING"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 4: Reference Files ---
|
||||
echo "--- Reference Files ---"
|
||||
|
||||
EXPECTED_REFS=(
|
||||
"engagement-frameworks" "collaborations-guide" "algorithm-signals-reference"
|
||||
"linkedin-growth-playbook-2025-2026" "opportunity-generation"
|
||||
"linkedin-formats" "ai-content-framework" "articles-strategy-guide"
|
||||
"first-comment-strategy" "poll-strategy-guide" "newsletter-strategy-guide"
|
||||
"linkedin-visual-style" "growth-roadmaps"
|
||||
"thought-leadership-angles" "low-frequency-posting-strategy"
|
||||
"url-processing-templates" "linkedin-monetization-strategies"
|
||||
"troubleshooting-guide" "glossary" "ab-testing-framework"
|
||||
)
|
||||
|
||||
for ref in "${EXPECTED_REFS[@]}"; do
|
||||
f="references/${ref}.md"
|
||||
if [ -f "$PLUGIN_ROOT/$f" ]; then
|
||||
pass "$f exists"
|
||||
else
|
||||
fail "$f MISSING"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 5: Skill Files ---
|
||||
echo "--- Skill Files ---"
|
||||
|
||||
for skill in "linkedin-thought-leadership" "linkedin-content-creation" "linkedin-analytics" "linkedin-strategy" "linkedin-networking" "linkedin-voice"; do
|
||||
f="skills/${skill}.md"
|
||||
if [ -f "$PLUGIN_ROOT/$f" ]; then
|
||||
pass "$f exists"
|
||||
else
|
||||
fail "$f MISSING"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 6: Hook Configuration ---
|
||||
echo "--- Hook Configuration ---"
|
||||
|
||||
HOOKS_FILE="$PLUGIN_ROOT/hooks/hooks.json"
|
||||
if [ -f "$HOOKS_FILE" ]; then
|
||||
pass "hooks/hooks.json exists"
|
||||
# Validate JSON
|
||||
if python3 -c "import json; json.load(open('$HOOKS_FILE'))" 2>/dev/null; then
|
||||
pass "hooks.json is valid JSON"
|
||||
else
|
||||
fail "hooks.json is INVALID JSON"
|
||||
fi
|
||||
else
|
||||
fail "hooks/hooks.json MISSING"
|
||||
fi
|
||||
|
||||
# Check hook prompt files
|
||||
for prompt in "content-quality-gate" "voice-guardian" "state-update-reminder" "post-creation-automation"; do
|
||||
f="hooks/prompts/${prompt}.md"
|
||||
if [ -f "$PLUGIN_ROOT/$f" ]; then
|
||||
pass "$f exists"
|
||||
else
|
||||
fail "$f MISSING"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 7: Plugin.json Validation ---
|
||||
echo "--- Plugin.json Validation ---"
|
||||
|
||||
PLUGIN_JSON="$PLUGIN_ROOT/.claude-plugin/plugin.json"
|
||||
if python3 -c "
|
||||
import json, sys
|
||||
with open('$PLUGIN_JSON') as f:
|
||||
data = json.load(f)
|
||||
required = ['name', 'version', 'auto_discover', 'description']
|
||||
for field in required:
|
||||
if field not in data:
|
||||
print(f'Missing field: {field}')
|
||||
sys.exit(1)
|
||||
if data.get('auto_discover') != True:
|
||||
print('auto_discover is not true')
|
||||
sys.exit(1)
|
||||
print(f'Version: {data[\"version\"]}')
|
||||
" 2>/dev/null; then
|
||||
pass "plugin.json structure valid"
|
||||
else
|
||||
fail "plugin.json structure invalid"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 8: Router Completeness ---
|
||||
echo "--- Router Completeness ---"
|
||||
|
||||
ROUTER="$PLUGIN_ROOT/commands/linkedin.md"
|
||||
if [ -f "$ROUTER" ]; then
|
||||
# Check that key commands are mentioned in router
|
||||
for cmd in "linkedin:setup" "linkedin:post" "linkedin:quick" "linkedin:report" "linkedin:import" "linkedin:ab-test" "linkedin:collab" "linkedin:pipeline" "linkedin:batch"; do
|
||||
if grep -q "$cmd" "$ROUTER"; then
|
||||
pass "Router references $cmd"
|
||||
else
|
||||
fail "Router MISSING reference to $cmd"
|
||||
fi
|
||||
done
|
||||
|
||||
# Check that key agents are mentioned
|
||||
for agent in "engagement-coach" "content-optimizer" "network-builder" "post-feedback-monitor" "personalization-scorer"; do
|
||||
if grep -q "$agent" "$ROUTER"; then
|
||||
pass "Router references $agent"
|
||||
else
|
||||
fail "Router MISSING reference to $agent"
|
||||
fi
|
||||
done
|
||||
else
|
||||
fail "Router file MISSING"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Section 9: Analytics Structure ---
|
||||
echo "--- Analytics Structure ---"
|
||||
|
||||
for d in "scripts/analytics/src" "assets/analytics"; do
|
||||
if [ -d "$PLUGIN_ROOT/$d" ]; then
|
||||
pass "$d/ directory exists"
|
||||
else
|
||||
fail "$d/ directory MISSING"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -f "$PLUGIN_ROOT/scripts/analytics/src/cli.ts" ]; then
|
||||
pass "scripts/analytics/src/cli.ts exists"
|
||||
else
|
||||
fail "scripts/analytics/src/cli.ts MISSING"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# --- Summary ---
|
||||
echo "================================================"
|
||||
echo "RESULTS"
|
||||
echo "================================================"
|
||||
echo -e "${GREEN}Passed: $PASS${NC}"
|
||||
echo -e "${RED}Failed: $FAIL${NC}"
|
||||
echo -e "${YELLOW}Warnings: $WARN${NC}"
|
||||
echo ""
|
||||
|
||||
if [ $FAIL -eq 0 ]; then
|
||||
echo -e "${GREEN}All structural checks passed!${NC}"
|
||||
exit 0
|
||||
else
|
||||
echo -e "${RED}$FAIL check(s) failed. Review above.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
Loading…
Add table
Add a link
Reference in a new issue