diff --git a/.opencode/todo.md b/.opencode/todo.md index 02518e4b..61d1f0cb 100644 --- a/.opencode/todo.md +++ b/.opencode/todo.md @@ -1,30 +1,22 @@ -# Mission Tasks for Issue #609 - Newsletters & Articles: make it production-grade +# Mission: Notifications: make it production-grade (#613) -## Phase 1: Support Rich Text and Images in Articles -- [ ] Investigate how LinkedIn rich text editor works (contenteditable vs specific DOM structure) -- [ ] Add support for cover image URL in `PrepareCreateArticleInput` and `PrepareCreateNewsletterInput` -- [ ] Add support for HTML/Markdown body parsing and inserting rich text +## M1: Analysis & Infrastructure +### T1.1: Identify Notification Types & Extracted Data | agent:Planner +- [x] S1.1.1: Document parsing logic for the 9 specified notification types | size:M +- [x] S1.1.2: Define updated `LinkedInNotification` interface with `extracted_data` | size:S -## Phase 2: Newsletter Metadata Editing -- [ ] Create `PrepareUpdateNewsletterInput` interface -- [ ] Implement `prepareUpdate` in `LinkedInNewslettersService` -- [ ] Implement `UpdateNewsletterActionExecutor` -- [ ] Register tool in MCP `linkedin.newsletter.prepare_update` -- [ ] Add CLI command for updating newsletter +## M2: Implementation +### T2.1: Implement Data Extraction (Rich Data) | agent:Worker | depends:T1.1 +- [x] S2.1.1: Update `extractNotificationSnapshots` to extract structured data for all required types | size:L +- [x] S2.1.2: Add parsing utilities for metrics (view counts, names, etc) | size:M -## Phase 3: Newsletter Editions List and Stats -- [ ] Update `list` method in `LinkedInNewslettersService` to fetch stats (subscribers, views) -- [ ] Add new interface `ListNewsletterEditionsInput` and `listEditions` method to list individual editions for a newsletter -- [ ] Register `linkedin.newsletter.list_editions` tool in MCP -- [ ] Add CLI command for listing editions +### T2.2: Implement Type Filtering & Pagination | agent:Worker | depends:T2.1 +- [x] S2.2.1: Add `types` filtering to `ListNotificationsInput` and `listNotifications` method | size:S +- [x] S2.2.2: Add `types` parameter to MCP tool `notifications_list` | size:S +- [x] S2.2.3: Update pagination logic in `loadNotificationSnapshots` to support fetching longer feeds without fixed scroll limits | size:M -## Phase 4: Share Newsletter -- [ ] Investigate how sharing works for newsletters (Share button -> modal -> post) -- [ ] Implement `prepareShare` in `LinkedInNewslettersService` -- [ ] Implement `ShareNewsletterActionExecutor` -- [ ] Register MCP tool and CLI command - -## Phase 5: Testing and Polish -- [ ] Add unit tests for new methods in `linkedinPublishing.test.ts` -- [ ] Run e2e tests -- [ ] Address edge cases: draft vs published, editing after publish, newsletter with zero editions +## M3: Verification & Integration +### T3.1: Testing & Quality Gates | agent:Reviewer | depends:M2 +- [x] S3.1.1: Run unit tests and e2e tests for notifications | size:M +- [x] S3.1.2: Run lint and typecheck | size:S +- [x] S3.1.3: Wait for CI and create PR | size:S diff --git a/.opencode/work-log.md b/.opencode/work-log.md new file mode 100644 index 00000000..b5923cd2 --- /dev/null +++ b/.opencode/work-log.md @@ -0,0 +1,11 @@ +# Work Log + +## Active Sessions + +## Completed Units (Ready for Integration) +| File | Session | Unit Test | Timestamp | +|------|---------|-----------|-----------| +| packages/core/src/linkedinNotifications.ts | Worker | pass | 2026-03-23T15:42:58.981Z | +| packages/mcp/src/bin/linkedin-mcp.ts | Worker | pass | 2026-03-23T15:42:58.981Z | + +## Pending Integration diff --git a/packages/cli/src/bin/linkedin.ts b/packages/cli/src/bin/linkedin.ts index e91bc043..69189c8e 100644 --- a/packages/cli/src/bin/linkedin.ts +++ b/packages/cli/src/bin/linkedin.ts @@ -8515,6 +8515,7 @@ async function runNotificationsList( input: { profileName: string; limit: number; + types?: string[]; }, cdpUrl?: string, ): Promise { @@ -8524,11 +8525,13 @@ async function runNotificationsList( runtime.logger.log("info", "cli.notifications.list.start", { profileName: input.profileName, limit: input.limit, + types: input.types, }); const notifications = await runtime.notifications.listNotifications({ profileName: input.profileName, limit: input.limit, + ...(input.types ? { types: input.types } : {}), }); runtime.logger.log("info", "cli.notifications.list.done", { @@ -12799,11 +12802,13 @@ export function createCliProgram(): Command { .description("List your LinkedIn notifications") .option("-p, --profile ", "Profile name", "default") .option("-l, --limit ", "Max notifications to return", "20") - .action(async (options: { profile: string; limit: string }) => { + .option("-t, --types ", "Filter by notification types") + .action(async (options: { profile: string; limit: string; types?: string[] }) => { await runNotificationsList( { profileName: options.profile, limit: coercePositiveInt(options.limit, "limit"), + ...(options.types ? { types: options.types } : {}), }, readCdpUrl(), ); diff --git a/packages/core/src/__tests__/linkedinNotifications.test.ts b/packages/core/src/__tests__/linkedinNotifications.test.ts index 7f8ccd23..235813a7 100644 --- a/packages/core/src/__tests__/linkedinNotifications.test.ts +++ b/packages/core/src/__tests__/linkedinNotifications.test.ts @@ -4,6 +4,7 @@ import { LINKEDIN_NOTIFICATION_PREFERENCE_CHANNELS, _hashNotificationFingerprint as hashNotificationFingerprint, _legacyHashNotificationFingerprint as legacyHashNotificationFingerprint, + _extractNotificationStructuredData as extractNotificationStructuredData, _normalizeNotificationLink as normalizeNotificationLink, _stripVolatileContent as stripVolatileContent, LinkedInNotificationsService, @@ -1083,3 +1084,122 @@ describe("legacyHashNotificationFingerprint", () => { expect(id).toMatch(/^notif_[0-9a-f]{16}$/); }); }); + +describe("extractNotificationStructuredData", () => { + it("extracts post analytics from 'Your post has ... views'", () => { + expect(extractNotificationStructuredData("Your post has 1,234 views")).toEqual({ + views: 1234, + }); + }); + + it("extracts post analytics from 'people viewed your post'", () => { + expect(extractNotificationStructuredData("500 people viewed your post")).toEqual({ + views: 500, + }); + }); + + it("extracts profile views from 'people viewed your profile'", () => { + expect(extractNotificationStructuredData("42 people viewed your profile")).toEqual({ + profile_views: 42, + }); + }); + + it("extracts profile views from 'Your profile was viewed by ... people'", () => { + expect( + extractNotificationStructuredData("Your profile was viewed by 100 people"), + ).toEqual({ + profile_views: 100, + }); + }); + + it("extracts search appearances", () => { + expect(extractNotificationStructuredData("You appeared in 15 searches")).toEqual({ + search_appearances: 15, + }); + }); + + it("extracts mention sender", () => { + expect(extractNotificationStructuredData("John Smith mentioned you")).toEqual({ + mentioned_by: "John Smith", + }); + }); + + it("extracts connection sender from connection request", () => { + expect( + extractNotificationStructuredData("Jane Doe sent you a connection request"), + ).toEqual({ + sender: "Jane Doe", + }); + }); + + it("extracts connection sender from accepted connection", () => { + expect(extractNotificationStructuredData("Bob accepted your connection")).toEqual({ + sender: "Bob", + }); + }); + + it("extracts newsletter subscriber count", () => { + expect(extractNotificationStructuredData("1,500 people subscribed to")).toEqual({ + subscriber_count: 1500, + }); + }); + + it("extracts newsletter subscriber name", () => { + expect(extractNotificationStructuredData("Alice subscribed to")).toEqual({ + subscriber: "Alice", + }); + }); + + it("extracts job alert count and title", () => { + expect( + extractNotificationStructuredData('5 new jobs for "Software Engineer"'), + ).toEqual({ + job_count: 5, + job_title: "Software Engineer", + }); + }); + + it("extracts single job alert title", () => { + expect(extractNotificationStructuredData('new job for "Designer"')).toEqual({ + job_title: "Designer", + }); + }); + + it("extracts company name from posted notification", () => { + expect(extractNotificationStructuredData("Google posted:")).toEqual({ + company_name: "Google", + }); + }); + + it("extracts company name from shared post notification", () => { + expect(extractNotificationStructuredData("Microsoft shared a post:")).toEqual({ + company_name: "Microsoft", + }); + }); + + it("extracts trending topic", () => { + expect( + extractNotificationStructuredData("Trending: AI advances in 2025"), + ).toEqual({ + topic: "AI advances in 2025", + }); + }); + + it("returns undefined when message does not match known parsers", () => { + expect( + extractNotificationStructuredData("Someone liked your comment"), + ).toBeUndefined(); + }); + + it("returns undefined for empty string", () => { + expect(extractNotificationStructuredData("")).toBeUndefined(); + }); + + it("normalizes excessive whitespace before parsing", () => { + expect( + extractNotificationStructuredData(" John Smith mentioned\n\t you "), + ).toEqual({ + mentioned_by: "John Smith", + }); + }); +}); diff --git a/packages/core/src/linkedinNotifications.ts b/packages/core/src/linkedinNotifications.ts index 9d3a8f82..3e8f2680 100644 --- a/packages/core/src/linkedinNotifications.ts +++ b/packages/core/src/linkedinNotifications.ts @@ -30,11 +30,13 @@ export interface LinkedInNotification { timestamp: string; link: string; is_read: boolean; + extracted_data?: Record | undefined; } export interface ListNotificationsInput { profileName?: string; limit?: number; + types?: string[]; } export interface MarkNotificationReadInput { @@ -155,6 +157,7 @@ interface NotificationSnapshot { link: string; is_read: boolean; card_index: number; + extracted_data?: Record; } interface NotificationSnapshotCandidate { @@ -165,6 +168,7 @@ interface NotificationSnapshotCandidate { link: string; is_read: boolean; card_index: number; + extracted_data?: Record; } interface NotificationCardMatch { @@ -289,9 +293,10 @@ function hashNotificationFingerprint(input: { // content stripping keep IDs stable across separate page loads. const normalizedLink = normalizeNotificationLink(input.link) || normalizeText(input.link); - const fingerprint = [normalizedLink, stripVolatileContent(input.message)].join( - "\u001f", - ); + const fingerprint = [ + normalizedLink, + stripVolatileContent(input.message), + ].join("\u001f"); return `notif_${createHash("sha256").update(fingerprint).digest("hex").slice(0, 16)}`; } @@ -587,6 +592,124 @@ async function extractNotificationSnapshots( return ""; }; + const extractStructuredData = ( + message: string, + ): Record | undefined => { + const data: Record = {}; + let matched = false; + const text = message.replace(/\s+/g, " ").trim(); + + const postAnalyticsMatch = + text.match( + /Your post (?:has|got|was seen by) ([\d,]+) (?:views|impressions|times)/i, + ) || text.match(/([\d,]+) people viewed your post/i); + if (postAnalyticsMatch) { + data.views = parseInt( + (postAnalyticsMatch[1] || "0").replace(/,/g, ""), + 10, + ); + matched = true; + } + + const profileViewsMatch = + text.match(/([\d,]+) people viewed your profile/i) || + text.match(/Your profile was viewed by ([\d,]+) people/i) || + text.match(/You appeared in ([\d,]+) searches/i); + if ( + profileViewsMatch && + !text.match(/You appeared in ([\d,]+) searches/i) + ) { + data.profile_views = parseInt( + (profileViewsMatch[1] || "0").replace(/,/g, ""), + 10, + ); + matched = true; + } + + const searchMatch = text.match(/You appeared in ([\d,]+) searches/i); + if (searchMatch) { + data.search_appearances = parseInt( + (searchMatch[1] || "0").replace(/,/g, ""), + 10, + ); + matched = true; + } + + const mentionMatch = text.match(/^(.*?)\s+mentioned you/i); + if (mentionMatch) { + data.mentioned_by = (mentionMatch[1] || "").trim(); + matched = true; + } + + const connectionMatch = + text.match(/^(.*?)\s+sent you a connection request/i) || + text.match(/^(.*?)\s+wants to connect/i) || + text.match(/^(.*?)\s+accepted your connection/i); + if (connectionMatch) { + data.sender = (connectionMatch[1] || "").trim(); + matched = true; + } + + const newsletterMatch = + text.match(/([\d,]+)\s+people subscribed to/i) || + text.match(/^(.*?)\s+subscribed to/i); + if (newsletterMatch) { + const num = parseInt( + (newsletterMatch[1] || "").replace(/,/g, ""), + 10, + ); + if (!isNaN(num)) { + data.subscriber_count = num; + } else { + data.subscriber = (newsletterMatch[1] || "").trim(); + } + matched = true; + } + + const jobAlertMatch = + text.match(/([\d,]+)\s+new jobs? for "(.*?)"/i) || + text.match(/new jobs? for "(.*?)"/i) || + text.match(/([\d,]+)\s+new jobs? for (.*)/i); + if (jobAlertMatch) { + if (jobAlertMatch.length === 3) { + data.job_count = parseInt( + (jobAlertMatch[1] || "0").replace(/,/g, ""), + 10, + ); + data.job_title = (jobAlertMatch[2] || "").trim(); + } else if (jobAlertMatch[1]) { + if (!isNaN(parseInt(jobAlertMatch[1], 10))) { + data.job_count = parseInt( + (jobAlertMatch[1] || "0").replace(/,/g, ""), + 10, + ); + data.job_title = jobAlertMatch[2] + ? (jobAlertMatch[2] || "").trim() + : ""; + } else { + data.job_title = (jobAlertMatch[1] || "").trim(); + } + } + matched = true; + } + + const companyPostMatch = + text.match(/^(.*?)\s+posted:/i) || + text.match(/^(.*?)\s+shared a post:/i); + if (companyPostMatch) { + data.company_name = (companyPostMatch[1] || "").trim(); + matched = true; + } + + const trendingMatch = text.match(/^Trending:\s+(.*)/i); + if (trendingMatch) { + data.topic = (trendingMatch[1] || "").trim(); + matched = true; + } + + return matched ? data : undefined; + }; + const readClassName = (node: Element | null | undefined): string => { if (!node) { return ""; @@ -758,6 +881,8 @@ async function extractNotificationSnapshots( "[data-test-time-ago]", ]) || normalize(card.querySelector("time")?.getAttribute("datetime")); + const extractedData = extractStructuredData(message); + notifications.push({ raw_id: readRawNotificationId(card), type: inferType(card), @@ -766,6 +891,7 @@ async function extractNotificationSnapshots( link, is_read: inferReadState(card), card_index: i, + ...(extractedData ? { extracted_data: extractedData } : {}), }); if (notifications.length >= maxNotifications) { @@ -800,6 +926,9 @@ async function extractNotificationSnapshots( link, is_read: Boolean(candidate.is_read), card_index: Math.max(0, Math.floor(candidate.card_index)), + ...(candidate.extracted_data + ? { extracted_data: candidate.extracted_data as Record } + : {}), } satisfies NotificationSnapshot; }) .filter( @@ -815,16 +944,38 @@ async function extractNotificationSnapshots( async function loadNotificationSnapshots( page: Page, limit: number, + types?: string[] ): Promise { - let notifications = await extractNotificationSnapshots(page, limit); + const isMatch = (n: NotificationSnapshot) => { + if (!types || types.length === 0) return true; + return types.includes(n.type); + }; + + const maxScrolls = 20; + let scrollCount = 0; + let previousCount = 0; + let allNotifications: NotificationSnapshot[] = []; + let matchedCount = 0; - for (let i = 0; i < 6 && notifications.length < limit; i += 1) { - await scrollLinkedInPageToBottom(page); - await page.waitForTimeout(800); - notifications = await extractNotificationSnapshots(page, limit); + while (matchedCount < limit && scrollCount < maxScrolls) { + // Extract more to ensure we can find filtered items + const extractLimit = Math.max(limit, 200); + allNotifications = await extractNotificationSnapshots(page, extractLimit); + matchedCount = allNotifications.filter(isMatch).length; + + if (allNotifications.length === previousCount) { + break; // No new items loaded + } + previousCount = allNotifications.length; + + if (matchedCount < limit) { + await scrollLinkedInPageToBottom(page); + await page.waitForTimeout(800); + scrollCount += 1; + } } - return notifications.slice(0, Math.max(1, limit)); + return allNotifications.filter(isMatch).slice(0, Math.max(1, limit)); } async function findNotificationCard( @@ -841,12 +992,16 @@ async function findNotificationCard( function toMatch(snapshot: NotificationSnapshot): NotificationCardMatch { return { snapshot, - locator: page.locator(NOTIFICATION_CARD_SELECTOR).nth(snapshot.card_index), + locator: page + .locator(NOTIFICATION_CARD_SELECTOR) + .nth(snapshot.card_index), }; } // Strategy 1: Exact ID match (native LinkedIn IDs or current-algorithm hashes). - const exactMatch = snapshots.find((candidate) => candidate.id === normalizedId); + const exactMatch = snapshots.find( + (candidate) => candidate.id === normalizedId, + ); if (exactMatch) { return toMatch(exactMatch); } @@ -1178,7 +1333,9 @@ async function readNotificationPreferencePageState( const href = normalize(anchor.href || anchor.getAttribute("href")); const text = normalize(anchor.textContent); const match = - /^(.+?)\s+((?:On|Off|Push|In-app|Email)(?:[\s,]+(?:and\s+)?(?:On|Off|Push|In-app|Email))*)$/iu.exec(text); + /^(.+?)\s+((?:On|Off|Push|In-app|Email)(?:[\s,]+(?:and\s+)?(?:On|Off|Push|In-app|Email))*)$/iu.exec( + text, + ); return { title: normalize(match?.[1] ?? text), slug: href.replace(/\/+$/u, "").split("/").pop() ?? "", @@ -1584,9 +1741,10 @@ export class LinkedInNotificationsService { async (context) => { const page = await getOrCreatePage(context); await openNotificationsPage(page); - const notifications = await loadNotificationSnapshots(page, limit); - return notifications.map((notification) => { - return { + const notifications = await loadNotificationSnapshots(page, limit, input.types); + + return notifications.map((notification): LinkedInNotification => { + const result: LinkedInNotification = { id: notification.id, type: notification.type, message: notification.message, @@ -1594,6 +1752,10 @@ export class LinkedInNotificationsService { link: notification.link, is_read: notification.is_read, }; + if (notification.extracted_data) { + result.extracted_data = notification.extracted_data; + } + return result; }); }, ); @@ -1933,6 +2095,133 @@ export class LinkedInNotificationsService { } } +/** + * Extract structured data from a notification message string. + * + * This is a standalone mirror of the browser-inline `extractStructuredData()` + * inside `page.evaluate()`. The browser version must remain self-contained + * (Playwright serialization), so this module-level copy exists for testing. + * + * Keep both versions in sync when modifying regex patterns. + */ +function extractNotificationStructuredData( + message: string, +): Record | undefined { + const data: Record = {}; + let matched = false; + const text = message.replace(/\s+/g, " ").trim(); + + const postAnalyticsMatch = + text.match( + /Your post (?:has|got|was seen by) ([\d,]+) (?:views|impressions|times)/i, + ) || text.match(/([\d,]+) people viewed your post/i); + if (postAnalyticsMatch) { + data.views = parseInt( + (postAnalyticsMatch[1] || "0").replace(/,/g, ""), + 10, + ); + matched = true; + } + + const profileViewsMatch = + text.match(/([\d,]+) people viewed your profile/i) || + text.match(/Your profile was viewed by ([\d,]+) people/i) || + text.match(/You appeared in ([\d,]+) searches/i); + if ( + profileViewsMatch && + !text.match(/You appeared in ([\d,]+) searches/i) + ) { + data.profile_views = parseInt( + (profileViewsMatch[1] || "0").replace(/,/g, ""), + 10, + ); + matched = true; + } + + const searchMatch = text.match(/You appeared in ([\d,]+) searches/i); + if (searchMatch) { + data.search_appearances = parseInt( + (searchMatch[1] || "0").replace(/,/g, ""), + 10, + ); + matched = true; + } + + const mentionMatch = text.match(/^(.*?)\s+mentioned you/i); + if (mentionMatch) { + data.mentioned_by = (mentionMatch[1] || "").trim(); + matched = true; + } + + const connectionMatch = + text.match(/^(.*?)\s+sent you a connection request/i) || + text.match(/^(.*?)\s+wants to connect/i) || + text.match(/^(.*?)\s+accepted your connection/i); + if (connectionMatch) { + data.sender = (connectionMatch[1] || "").trim(); + matched = true; + } + + const newsletterMatch = + text.match(/([\d,]+)\s+people subscribed to/i) || + text.match(/^(.*?)\s+subscribed to/i); + if (newsletterMatch) { + const num = parseInt( + (newsletterMatch[1] || "").replace(/,/g, ""), + 10, + ); + if (!isNaN(num)) { + data.subscriber_count = num; + } else { + data.subscriber = (newsletterMatch[1] || "").trim(); + } + matched = true; + } + + const jobAlertMatch = + text.match(/([\d,]+)\s+new jobs? for "(.*?)"/i) || + text.match(/new jobs? for "(.*?)"/i) || + text.match(/([\d,]+)\s+new jobs? for (.*)/i); + if (jobAlertMatch) { + if (jobAlertMatch.length === 3) { + data.job_count = parseInt( + (jobAlertMatch[1] || "0").replace(/,/g, ""), + 10, + ); + data.job_title = (jobAlertMatch[2] || "").trim(); + } else if (jobAlertMatch[1]) { + if (!isNaN(parseInt(jobAlertMatch[1], 10))) { + data.job_count = parseInt( + (jobAlertMatch[1] || "0").replace(/,/g, ""), + 10, + ); + data.job_title = jobAlertMatch[2] + ? (jobAlertMatch[2] || "").trim() + : ""; + } else { + data.job_title = (jobAlertMatch[1] || "").trim(); + } + } + matched = true; + } + + const companyPostMatch = + text.match(/^(.*?)\s+posted:/i) || + text.match(/^(.*?)\s+shared a post:/i); + if (companyPostMatch) { + data.company_name = (companyPostMatch[1] || "").trim(); + matched = true; + } + + const trendingMatch = text.match(/^Trending:\s+(.*)/i); + if (trendingMatch) { + data.topic = (trendingMatch[1] || "").trim(); + matched = true; + } + + return matched ? data : undefined; +} + /** @internal Exported for testing — normalizes notification link URLs for stable comparison. */ export { normalizeNotificationLink as _normalizeNotificationLink }; /** @internal Exported for testing — strips volatile numeric content from messages. */ @@ -1941,3 +2230,5 @@ export { stripVolatileContent as _stripVolatileContent }; export { hashNotificationFingerprint as _hashNotificationFingerprint }; /** @internal Exported for testing — pre-normalization fingerprint algorithm. */ export { legacyHashNotificationFingerprint as _legacyHashNotificationFingerprint }; +/** @internal Exported for testing — extracts structured data from notification messages. */ +export { extractNotificationStructuredData as _extractNotificationStructuredData }; diff --git a/packages/mcp/src/bin/linkedin-mcp.ts b/packages/mcp/src/bin/linkedin-mcp.ts index 85a708b1..1ac46975 100644 --- a/packages/mcp/src/bin/linkedin-mcp.ts +++ b/packages/mcp/src/bin/linkedin-mcp.ts @@ -1769,15 +1769,18 @@ async function handleNotificationsList(args: ToolArgs): Promise { try { const profileName = readString(args, "profileName", "default"); const limit = readPositiveNumber(args, "limit", 20); + const types = args.types as string[] | undefined; runtime.logger.log("info", "mcp.notifications.list.start", { profileName, limit, + types, }); const notifications = await runtime.notifications.listNotifications({ profileName, limit, + ...(types && types.length > 0 ? { types } : {}), }); runtime.logger.log("info", "mcp.notifications.list.done", { @@ -6692,6 +6695,14 @@ export const LINKEDIN_MCP_TOOL_DEFINITIONS: LinkedInMcpToolDefinition[] = [ description: "Maximum number of notifications to return. Defaults to 20.", }, + types: { + type: "array", + items: { + type: "string", + }, + description: + "Optional array of notification types or categories to filter by (e.g. ['mention', 'profile_views', 'job_alert']).", + }, }), }, },