Skip to content

Commit f2f5594

Browse files
authored
feat: extract structured data from notifications (#617)
* feat: extract structured data from notifications * fix: typing errors * chore: add work-log * chore: update todo list * fix: harden notification extraction with type safety, caching, and unit tests - Cache extractStructuredData result instead of calling twice per notification - Replace unsafe double-cast (as unknown as LinkedInNotification) with properly typed object - Remove dead notification_category filter condition from isMatch() - Add module-level _extractNotificationStructuredData export for testing - Add --types option to CLI notifications list command for parity with MCP - Add 18 unit tests covering all 9 regex parsers and edge cases
1 parent f60f41f commit f2f5594

File tree

6 files changed

+472
-42
lines changed

6 files changed

+472
-42
lines changed

.opencode/todo.md

Lines changed: 18 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,22 @@
1-
# Mission Tasks for Issue #609 - Newsletters & Articles: make it production-grade
1+
# Mission: Notifications: make it production-grade (#613)
22

3-
## Phase 1: Support Rich Text and Images in Articles
4-
- [ ] Investigate how LinkedIn rich text editor works (contenteditable vs specific DOM structure)
5-
- [ ] Add support for cover image URL in `PrepareCreateArticleInput` and `PrepareCreateNewsletterInput`
6-
- [ ] Add support for HTML/Markdown body parsing and inserting rich text
3+
## M1: Analysis & Infrastructure
4+
### T1.1: Identify Notification Types & Extracted Data | agent:Planner
5+
- [x] S1.1.1: Document parsing logic for the 9 specified notification types | size:M
6+
- [x] S1.1.2: Define updated `LinkedInNotification` interface with `extracted_data` | size:S
77

8-
## Phase 2: Newsletter Metadata Editing
9-
- [ ] Create `PrepareUpdateNewsletterInput` interface
10-
- [ ] Implement `prepareUpdate` in `LinkedInNewslettersService`
11-
- [ ] Implement `UpdateNewsletterActionExecutor`
12-
- [ ] Register tool in MCP `linkedin.newsletter.prepare_update`
13-
- [ ] Add CLI command for updating newsletter
8+
## M2: Implementation
9+
### T2.1: Implement Data Extraction (Rich Data) | agent:Worker | depends:T1.1
10+
- [x] S2.1.1: Update `extractNotificationSnapshots` to extract structured data for all required types | size:L
11+
- [x] S2.1.2: Add parsing utilities for metrics (view counts, names, etc) | size:M
1412

15-
## Phase 3: Newsletter Editions List and Stats
16-
- [ ] Update `list` method in `LinkedInNewslettersService` to fetch stats (subscribers, views)
17-
- [ ] Add new interface `ListNewsletterEditionsInput` and `listEditions` method to list individual editions for a newsletter
18-
- [ ] Register `linkedin.newsletter.list_editions` tool in MCP
19-
- [ ] Add CLI command for listing editions
13+
### T2.2: Implement Type Filtering & Pagination | agent:Worker | depends:T2.1
14+
- [x] S2.2.1: Add `types` filtering to `ListNotificationsInput` and `listNotifications` method | size:S
15+
- [x] S2.2.2: Add `types` parameter to MCP tool `notifications_list` | size:S
16+
- [x] S2.2.3: Update pagination logic in `loadNotificationSnapshots` to support fetching longer feeds without fixed scroll limits | size:M
2017

21-
## Phase 4: Share Newsletter
22-
- [ ] Investigate how sharing works for newsletters (Share button -> modal -> post)
23-
- [ ] Implement `prepareShare` in `LinkedInNewslettersService`
24-
- [ ] Implement `ShareNewsletterActionExecutor`
25-
- [ ] Register MCP tool and CLI command
26-
27-
## Phase 5: Testing and Polish
28-
- [ ] Add unit tests for new methods in `linkedinPublishing.test.ts`
29-
- [ ] Run e2e tests
30-
- [ ] Address edge cases: draft vs published, editing after publish, newsletter with zero editions
18+
## M3: Verification & Integration
19+
### T3.1: Testing & Quality Gates | agent:Reviewer | depends:M2
20+
- [x] S3.1.1: Run unit tests and e2e tests for notifications | size:M
21+
- [x] S3.1.2: Run lint and typecheck | size:S
22+
- [x] S3.1.3: Wait for CI and create PR | size:S

.opencode/work-log.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# Work Log
2+
3+
## Active Sessions
4+
5+
## Completed Units (Ready for Integration)
6+
| File | Session | Unit Test | Timestamp |
7+
|------|---------|-----------|-----------|
8+
| packages/core/src/linkedinNotifications.ts | Worker | pass | 2026-03-23T15:42:58.981Z |
9+
| packages/mcp/src/bin/linkedin-mcp.ts | Worker | pass | 2026-03-23T15:42:58.981Z |
10+
11+
## Pending Integration

packages/cli/src/bin/linkedin.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8549,6 +8549,7 @@ async function runNotificationsList(
85498549
input: {
85508550
profileName: string;
85518551
limit: number;
8552+
types?: string[];
85528553
},
85538554
cdpUrl?: string,
85548555
): Promise<void> {
@@ -8558,11 +8559,13 @@ async function runNotificationsList(
85588559
runtime.logger.log("info", "cli.notifications.list.start", {
85598560
profileName: input.profileName,
85608561
limit: input.limit,
8562+
types: input.types,
85618563
});
85628564

85638565
const notifications = await runtime.notifications.listNotifications({
85648566
profileName: input.profileName,
85658567
limit: input.limit,
8568+
...(input.types ? { types: input.types } : {}),
85668569
});
85678570

85688571
runtime.logger.log("info", "cli.notifications.list.done", {
@@ -12889,11 +12892,13 @@ export function createCliProgram(): Command {
1288912892
.description("List your LinkedIn notifications")
1289012893
.option("-p, --profile <profile>", "Profile name", "default")
1289112894
.option("-l, --limit <limit>", "Max notifications to return", "20")
12892-
.action(async (options: { profile: string; limit: string }) => {
12895+
.option("-t, --types <types...>", "Filter by notification types")
12896+
.action(async (options: { profile: string; limit: string; types?: string[] }) => {
1289312897
await runNotificationsList(
1289412898
{
1289512899
profileName: options.profile,
1289612900
limit: coercePositiveInt(options.limit, "limit"),
12901+
...(options.types ? { types: options.types } : {}),
1289712902
},
1289812903
readCdpUrl(),
1289912904
);

packages/core/src/__tests__/linkedinNotifications.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
LINKEDIN_NOTIFICATION_PREFERENCE_CHANNELS,
55
_hashNotificationFingerprint as hashNotificationFingerprint,
66
_legacyHashNotificationFingerprint as legacyHashNotificationFingerprint,
7+
_extractNotificationStructuredData as extractNotificationStructuredData,
78
_normalizeNotificationLink as normalizeNotificationLink,
89
_stripVolatileContent as stripVolatileContent,
910
LinkedInNotificationsService,
@@ -1083,3 +1084,122 @@ describe("legacyHashNotificationFingerprint", () => {
10831084
expect(id).toMatch(/^notif_[0-9a-f]{16}$/);
10841085
});
10851086
});
1087+
1088+
describe("extractNotificationStructuredData", () => {
1089+
it("extracts post analytics from 'Your post has ... views'", () => {
1090+
expect(extractNotificationStructuredData("Your post has 1,234 views")).toEqual({
1091+
views: 1234,
1092+
});
1093+
});
1094+
1095+
it("extracts post analytics from 'people viewed your post'", () => {
1096+
expect(extractNotificationStructuredData("500 people viewed your post")).toEqual({
1097+
views: 500,
1098+
});
1099+
});
1100+
1101+
it("extracts profile views from 'people viewed your profile'", () => {
1102+
expect(extractNotificationStructuredData("42 people viewed your profile")).toEqual({
1103+
profile_views: 42,
1104+
});
1105+
});
1106+
1107+
it("extracts profile views from 'Your profile was viewed by ... people'", () => {
1108+
expect(
1109+
extractNotificationStructuredData("Your profile was viewed by 100 people"),
1110+
).toEqual({
1111+
profile_views: 100,
1112+
});
1113+
});
1114+
1115+
it("extracts search appearances", () => {
1116+
expect(extractNotificationStructuredData("You appeared in 15 searches")).toEqual({
1117+
search_appearances: 15,
1118+
});
1119+
});
1120+
1121+
it("extracts mention sender", () => {
1122+
expect(extractNotificationStructuredData("John Smith mentioned you")).toEqual({
1123+
mentioned_by: "John Smith",
1124+
});
1125+
});
1126+
1127+
it("extracts connection sender from connection request", () => {
1128+
expect(
1129+
extractNotificationStructuredData("Jane Doe sent you a connection request"),
1130+
).toEqual({
1131+
sender: "Jane Doe",
1132+
});
1133+
});
1134+
1135+
it("extracts connection sender from accepted connection", () => {
1136+
expect(extractNotificationStructuredData("Bob accepted your connection")).toEqual({
1137+
sender: "Bob",
1138+
});
1139+
});
1140+
1141+
it("extracts newsletter subscriber count", () => {
1142+
expect(extractNotificationStructuredData("1,500 people subscribed to")).toEqual({
1143+
subscriber_count: 1500,
1144+
});
1145+
});
1146+
1147+
it("extracts newsletter subscriber name", () => {
1148+
expect(extractNotificationStructuredData("Alice subscribed to")).toEqual({
1149+
subscriber: "Alice",
1150+
});
1151+
});
1152+
1153+
it("extracts job alert count and title", () => {
1154+
expect(
1155+
extractNotificationStructuredData('5 new jobs for "Software Engineer"'),
1156+
).toEqual({
1157+
job_count: 5,
1158+
job_title: "Software Engineer",
1159+
});
1160+
});
1161+
1162+
it("extracts single job alert title", () => {
1163+
expect(extractNotificationStructuredData('new job for "Designer"')).toEqual({
1164+
job_title: "Designer",
1165+
});
1166+
});
1167+
1168+
it("extracts company name from posted notification", () => {
1169+
expect(extractNotificationStructuredData("Google posted:")).toEqual({
1170+
company_name: "Google",
1171+
});
1172+
});
1173+
1174+
it("extracts company name from shared post notification", () => {
1175+
expect(extractNotificationStructuredData("Microsoft shared a post:")).toEqual({
1176+
company_name: "Microsoft",
1177+
});
1178+
});
1179+
1180+
it("extracts trending topic", () => {
1181+
expect(
1182+
extractNotificationStructuredData("Trending: AI advances in 2025"),
1183+
).toEqual({
1184+
topic: "AI advances in 2025",
1185+
});
1186+
});
1187+
1188+
it("returns undefined when message does not match known parsers", () => {
1189+
expect(
1190+
extractNotificationStructuredData("Someone liked your comment"),
1191+
).toBeUndefined();
1192+
});
1193+
1194+
it("returns undefined for empty string", () => {
1195+
expect(extractNotificationStructuredData("")).toBeUndefined();
1196+
});
1197+
1198+
it("normalizes excessive whitespace before parsing", () => {
1199+
expect(
1200+
extractNotificationStructuredData(" John Smith mentioned\n\t you "),
1201+
).toEqual({
1202+
mentioned_by: "John Smith",
1203+
});
1204+
});
1205+
});

0 commit comments

Comments
 (0)