diff --git a/packages/core/src/linkedinFeed.ts b/packages/core/src/linkedinFeed.ts index dda0e2e8..c86b3766 100644 --- a/packages/core/src/linkedinFeed.ts +++ b/packages/core/src/linkedinFeed.ts @@ -40,6 +40,7 @@ import { escapeCssAttributeValue, isAbsoluteUrl, isLocatorVisible, + isPageClosedError, } from "./shared.js"; import type { ActionExecutor, @@ -1597,6 +1598,7 @@ async function clickPostMoreMenuAction(input: { | readonly LinkedInSelectorPhraseKey[]; candidateKeyPrefix: string; selectorKey: string; + onActionCommitted?: () => void; }): Promise { const menuActionCandidates = createPageMenuActionCandidates({ selectorLocale: input.selectorLocale, @@ -1626,7 +1628,8 @@ async function clickPostMoreMenuAction(input: { input.selectorKey, )); - await menuAction.locator.click({ timeout: 5_000 }); + input.onActionCommitted?.(); + await menuAction.locator.click({ timeout: 5_000, noWaitAfter: true }); return `${triggerKey}:${menuAction.key}`; } @@ -2763,48 +2766,81 @@ export class CommentOnPostActionExecutor implements ActionExecutor isCommentVisibleInPost(targetPost.locator, text), - 12_000, - ); + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForPostSurface(page); + targetPost = await findTargetPostLocator(page, postUrl); + const reopenCommentKey = await expandCommentsForPost( + page, + targetPost.locator, + runtime.selectorLocale, + ); - if (!commentVerified) { - throw new LinkedInBuddyError( - "UNKNOWN", - "Comment action could not be verified on the target post.", - { - action_id: action.id, - profile_name: profileName, - post_url: postUrl, - post_identity: targetPost.postIdentity, - activity_id: targetPost.activityId, - reopen_comment_selector_key: reopenCommentKey ?? null, - text, - }, + const commentVerified = await waitForCondition( + async () => isCommentVisibleInPost(targetPost.locator, text), + 12_000, ); + + if (!commentVerified) { + throw new LinkedInBuddyError( + "UNKNOWN", + "Comment action could not be verified on the target post.", + { + action_id: action.id, + profile_name: profileName, + post_url: postUrl, + selector_key: reopenCommentKey, + post_identity: targetPost.postIdentity, + activity_id: targetPost.activityId, + }, + ); + } + } catch (error) { + if (actionCommitted && isPageClosedError(error)) { + return { + ok: true, + result: { + commented: true, + post_url: postUrl, + text, + rate_limit: formatRateLimitState(rateLimitState), + }, + artifacts: [], + }; + } + throw error; } const screenshotPath = `linkedin/screenshot-feed-comment-${Date.now()}.png`; - await captureScreenshotArtifact(runtime, page, screenshotPath, { - action: COMMENT_ON_POST_ACTION_TYPE, - action_id: action.id, - profile_name: profileName, - post_url: postUrl, - selector_key: submitButton.key, - post_selector_key: targetPost.key, - }); + try { + await captureScreenshotArtifact(runtime, page, screenshotPath, { + action: COMMENT_ON_POST_ACTION_TYPE, + action_id: action.id, + profile_name: profileName, + post_url: postUrl, + selector_key: submitButton.key, + post_selector_key: targetPost.key, + }); + } catch (error) { + if (actionCommitted && isPageClosedError(error)) { + return { + ok: true, + result: { + commented: true, + post_url: postUrl, + text, + rate_limit: formatRateLimitState(rateLimitState), + }, + artifacts: [], + }; + } + throw error; + } return { ok: true, @@ -3218,44 +3254,96 @@ export class SavePostActionExecutor implements ActionExecutor { + actionCommitted = true; + }, + }); + } catch (error) { + if (actionCommitted && isPageClosedError(error)) { + return { + ok: true, + result: { + saved: true, + already_saved: false, + post_url: postUrl, + rate_limit: formatRateLimitState(rateLimitState), + }, + artifacts: [], + }; + } + throw error; + } } let verified = initialSavedState === true; if (!verified) { - verified = await waitForCondition(async () => { - try { - return ( - (await readPostSavedState( - page, - targetPost.locator, - runtime.selectorLocale, - )) === true - ); - } catch { - return false; + try { + verified = await waitForCondition(async () => { + try { + return ( + (await readPostSavedState( + page, + targetPost.locator, + runtime.selectorLocale, + )) === true + ); + } catch { + return false; + } + }, 6_000); + } catch (error) { + if (actionCommitted && isPageClosedError(error)) { + return { + ok: true, + result: { + saved: true, + already_saved: false, + post_url: postUrl, + rate_limit: formatRateLimitState(rateLimitState), + }, + artifacts: [], + }; } - }, 6_000); + throw error; + } } if (!verified) { - await page.reload({ waitUntil: "domcontentloaded" }); - await waitForPostSurface(page); - targetPost = await findTargetPostLocator(page, postUrl); - verified = - (await readPostSavedState( - page, - targetPost.locator, - runtime.selectorLocale, - )) === true; + try { + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForPostSurface(page); + targetPost = await findTargetPostLocator(page, postUrl); + verified = + (await readPostSavedState( + page, + targetPost.locator, + runtime.selectorLocale, + )) === true; + } catch (error) { + if (actionCommitted && isPageClosedError(error)) { + return { + ok: true, + result: { + saved: true, + already_saved: false, + post_url: postUrl, + rate_limit: formatRateLimitState(rateLimitState), + }, + artifacts: [], + }; + } + throw error; + } } if (!verified) { @@ -3274,14 +3362,30 @@ export class SavePostActionExecutor implements ActionExecutor { + actionCommitted = true; + }, + }); + } catch (error) { + if (actionCommitted && isPageClosedError(error)) { + return { + ok: true, + result: { + saved: false, + already_unsaved: false, + post_url: postUrl, + rate_limit: formatRateLimitState(rateLimitState), + }, + artifacts: [], + }; + } + throw error; + } } let verified = initialSavedState === false; if (!verified) { - verified = await waitForCondition(async () => { - try { - return ( - (await readPostSavedState( - page, - targetPost.locator, - runtime.selectorLocale, - )) === false - ); - } catch { - return false; + try { + verified = await waitForCondition(async () => { + try { + return ( + (await readPostSavedState( + page, + targetPost.locator, + runtime.selectorLocale, + )) === false + ); + } catch { + return false; + } + }, 6_000); + } catch (error) { + if (actionCommitted && isPageClosedError(error)) { + return { + ok: true, + result: { + saved: false, + already_unsaved: false, + post_url: postUrl, + rate_limit: formatRateLimitState(rateLimitState), + }, + artifacts: [], + }; } - }, 6_000); + throw error; + } } if (!verified) { - await page.reload({ waitUntil: "domcontentloaded" }); - await waitForPostSurface(page); - targetPost = await findTargetPostLocator(page, postUrl); - verified = - (await readPostSavedState( - page, - targetPost.locator, - runtime.selectorLocale, - )) === false; + try { + await page.reload({ waitUntil: "domcontentloaded" }); + await waitForPostSurface(page); + targetPost = await findTargetPostLocator(page, postUrl); + verified = + (await readPostSavedState( + page, + targetPost.locator, + runtime.selectorLocale, + )) === false; + } catch (error) { + if (actionCommitted && isPageClosedError(error)) { + return { + ok: true, + result: { + saved: false, + already_unsaved: false, + post_url: postUrl, + rate_limit: formatRateLimitState(rateLimitState), + }, + artifacts: [], + }; + } + throw error; + } } if (!verified) { @@ -3429,14 +3585,30 @@ export class UnsavePostActionExecutor implements ActionExecutor, ): Promise { + let actionCommitted = false; const runtime = input.runtime; const action = input.action; const profileName = getProfileName(action.target); @@ -4568,7 +4570,10 @@ class CreatePostActionExecutor implements ActionExecutor !(await isAnyLocatorVisible(composerRoot)), 10_000, @@ -4576,6 +4581,14 @@ class CreatePostActionExecutor implements ActionExecutor, ): Promise { + let actionCommitted = false; const runtime = input.runtime; const action = input.action; const profileName = getProfileName(action.target); @@ -4852,7 +4869,10 @@ class CreateMediaPostActionExecutor implements ActionExecutor !(await isAnyLocatorVisible(composerRoot)), 10_000, @@ -4860,6 +4880,14 @@ class CreateMediaPostActionExecutor implements ActionExecutor, ): Promise { + let actionCommitted = false; const runtime = input.runtime; const action = input.action; const profileName = getProfileName(action.target); @@ -5161,7 +5193,10 @@ class CreatePollPostActionExecutor implements ActionExecutor !(await isAnyLocatorVisible(composerRoot)), 10_000, @@ -5169,6 +5204,14 @@ class CreatePollPostActionExecutor implements ActionExecutor, ): Promise { + let actionCommitted = false; const runtime = input.runtime; const action = input.action; const profileName = getProfileName(action.target); @@ -5452,6 +5499,14 @@ class EditPostActionExecutor implements ActionExecutor, ): Promise { + let actionCommitted = false; const runtime = input.runtime; const action = input.action; const profileName = getProfileName(action.target); @@ -5693,6 +5752,14 @@ class DeletePostActionExecutor implements ActionExecutor { return typeof value === "object" && value !== null && !Array.isArray(value); } +/** Returns true if the error indicates that the Playwright CDP connection, context, or page was destroyed/closed. */ +export function isPageClosedError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + const message = error.message; + return ( + message.includes("Target page, context or browser has been closed") || + message.includes("Target closed") || + message.includes("Browser has been closed") || + message.includes("Connection refused") || + message.includes("WebSocket error") || + message.includes("ECONNREFUSED") + ); +} + /** Escapes special regex metacharacters so the string can be used inside new RegExp(). */ export function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");