Add Twitter and LinkedIn publishers with Playwright sidecars

- Create twitter-post sidecar with Playwright for browser automation
- Create linkedin-post sidecar with Playwright for browser automation
- Add TwitterPublisher class that calls twitter-post sidecar
- Add LinkedInPublisher class that calls linkedin-post sidecar
- Register both publishers in PublisherManager via AppServiceProvider
- Update Posts index view to show Publish button for twitter/linkedin types

Both publishers use the same pattern as RedditPublisher:
- No API keys required (uses stored credentials from Social Accounts)
- Browser automation via Playwright
- Returns JSON result with success/failure and URL

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
This commit is contained in:
m105
2026-07-27 21:08:39 +03:00
parent 2a41328130
commit 44f979820f
16 changed files with 604 additions and 1 deletions
+3
View File
@@ -0,0 +1,3 @@
/node_modules
/test-results
/.env
+28
View File
@@ -0,0 +1,28 @@
# LinkedIn Post Sidecar
A Playwright acceptance test that logs into LinkedIn with supplied credentials and creates a post.
## One-time setup
cd sidecars/linkedin-post
npm install
npx playwright install chromium
## Usage
./run.sh --email=YOUR_EMAIL --password=YOUR_PASSWORD \
--post="Your post content here" [--headed]
Prints a single JSON line to stdout and exits 0 on success, exits 1 on failure:
{"success": true, "url": "https://www.linkedin.com/posts/..."}
{"success": false, "error": "Login failed: Invalid credentials"}
## Notes
- On a failed run, Playwright retains a screenshot and trace under `test-results/` for debugging.
These are gitignored and not committed, but they persist on disk.
- LinkedIn's UI changes periodically. If posting fails, check the selectors in `tests/linkedin-post.spec.js`
and update them using: `npx playwright codegen https://www.linkedin.com/login`
- LinkedIn may show CAPTCHAs or 2FA prompts which will cause the automation to fail.
- For company pages, you may need to switch to the page after login before posting.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "linkedin-post-sidecar",
"private": true,
"version": "1.0.0",
"description": "Playwright acceptance test that logs into LinkedIn and creates a post, invoked via run.sh",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.61.1"
}
}
@@ -0,0 +1,14 @@
/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = {
testDir: './tests',
fullyParallel: false,
workers: 1,
reporter: 'json',
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
};
export default config;
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
EMAIL=""
PASSWORD=""
POST_TEXT=""
HEADLESS_FLAG="true"
for arg in "$@"; do
case "$arg" in
--email=*) EMAIL="${arg#*=}" ;;
--password=*) PASSWORD="${arg#*=}" ;;
--post=*) POST_TEXT="${arg#*=}" ;;
--headed) HEADLESS_FLAG="false" ;;
*)
UNKNOWN_FLAG="${arg%%=*}"
echo "{\"success\": false, \"error\": \"Unknown argument: $UNKNOWN_FLAG\"}"
exit 1
;;
esac
done
MISSING=""
[ -z "$EMAIL" ] && MISSING="$MISSING --email"
[ -z "$PASSWORD" ] && MISSING="$MISSING --password"
[ -z "$POST_TEXT" ] && MISSING="$MISSING --post"
if [ -n "$MISSING" ]; then
echo "{\"success\": false, \"error\": \"Missing required argument(s):$MISSING\"}"
exit 1
fi
export LINKEDIN_EMAIL="$EMAIL"
export LINKEDIN_PASSWORD="$PASSWORD"
export POST_CONTENT="$POST_TEXT"
export HEADLESS="$HEADLESS_FLAG"
set +e
npx playwright test tests/linkedin-post.spec.js
PLAYWRIGHT_EXIT_CODE=$?
set -e
RESULT_FILE="test-results/result.json"
if [ -f "$RESULT_FILE" ]; then
cat "$RESULT_FILE"
echo
else
echo "{\"success\": false, \"error\": \"Test did not produce a result file\"}"
exit 1
fi
exit "$PLAYWRIGHT_EXIT_CODE"
@@ -0,0 +1,137 @@
// @ts-check
const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const RESULT_PATH = path.join(__dirname, '..', 'test-results', 'result.json');
function writeResult(result) {
fs.mkdirSync(path.dirname(RESULT_PATH), { recursive: true });
fs.writeFileSync(RESULT_PATH, JSON.stringify(result));
}
test.describe('linkedin post', () => {
let outcome = { success: false, error: 'Test did not complete' };
let outcomeFinalized = false;
test.afterEach(async () => {
writeResult(outcome);
});
test('logs into LinkedIn and creates a post', async ({ page }) => {
const email = process.env.LINKEDIN_EMAIL;
const password = process.env.LINKEDIN_PASSWORD;
const postContent = process.env.POST_CONTENT;
for (const [name, value] of Object.entries({
LINKEDIN_EMAIL: email,
LINKEDIN_PASSWORD: password,
POST_CONTENT: postContent,
})) {
if (!value) {
outcome = { success: false, error: `Missing required environment variable: ${name}` };
outcomeFinalized = true;
throw new Error(outcome.error);
}
}
try {
await page.goto('https://www.linkedin.com/login');
// Wait for page to load
await page.waitForLoadState('networkidle');
// Check for login form
const emailField = page.getByLabel(/email|phone/i).first();
if (!await emailField.isVisible({ timeout: 10000 }).catch(() => false)) {
outcome = {
success: false,
error: 'LinkedIn login form not found — page structure may have changed',
};
outcomeFinalized = true;
throw new Error(outcome.error);
}
// Fill email
await emailField.fill(email);
// Fill password
const passwordField = page.getByLabel(/password/i).first();
if (await passwordField.isVisible({ timeout: 10000 }).catch(() => false)) {
await passwordField.fill(password);
}
// Click sign in button
const signInButton = page.getByRole('button', { name: /sign in|signin|log in/i, exact: false }).first();
await signInButton.click();
// Wait for login to complete - check for feed or start post button
try {
await page.waitForURL('https://www.linkedin.com/feed/', { timeout: 20000 });
} catch {
// Check if we're logged in by looking for start post button
const startPostButton = page.getByRole('button', { name: /start a post|post/i, exact: false }).first();
if (!await startPostButton.isVisible({ timeout: 10000 }).catch(() => false)) {
const errorText = await page.textContent('body').catch(() => '');
outcome = {
success: false,
error: errorText.toLowerCase().includes('invalid') || errorText.toLowerCase().includes('incorrect')
? 'Invalid LinkedIn credentials'
: 'LinkedIn login did not complete — possible CAPTCHA, 2FA, or invalid credentials',
};
outcomeFinalized = true;
throw new Error(outcome.error);
}
}
// Click "Start a post" button
const startPostButton = page.getByRole('button', { name: /start a post|post/i, exact: false }).first();
await startPostButton.click();
// Wait for post composer
const postTextarea = page.getByRole('textbox', { name: /what do you want to talk about/i, exact: false }).first();
if (!await postTextarea.isVisible({ timeout: 10000 }).catch(() => false)) {
// Try alternative selector
const altTextarea = page.locator('div[contenteditable="true"]').first();
if (await altTextarea.isVisible({ timeout: 5000 }).catch(() => false)) {
await altTextarea.click();
await page.keyboard.type(postContent);
} else {
outcome = {
success: false,
error: 'LinkedIn post composer not found — UI may have changed',
};
outcomeFinalized = true;
throw new Error(outcome.error);
}
} else {
await postTextarea.fill(postContent);
}
// Click post button
const postButton = page.getByRole('button', { name: /^post$/i, exact: true }).first();
if (await postButton.isVisible({ timeout: 5000 }).catch(() => false)) {
await postButton.click();
} else {
// Try alternative post button selectors
const altPostButton = page.getByRole('button', { name: /share|post/i, exact: false }).nth(1);
if (await altPostButton.isVisible({ timeout: 5000 }).catch(() => false)) {
await altPostButton.click();
}
}
// Wait for post to be created
await page.waitForTimeout(3000);
// Get current URL (should be the post URL or feed)
outcome = { success: true, url: page.url() };
outcomeFinalized = true;
expect(outcome.success).toBe(true);
} catch (err) {
if (!outcomeFinalized) {
outcome = { success: false, error: err instanceof Error ? err.message : String(err) };
}
throw err;
}
});
});
+3
View File
@@ -0,0 +1,3 @@
/node_modules
/test-results
/.env
+27
View File
@@ -0,0 +1,27 @@
# Twitter/X Post Sidecar
A Playwright acceptance test that logs into Twitter/X with supplied credentials and creates a post.
## One-time setup
cd sidecars/twitter-post
npm install
npx playwright install chromium
## Usage
./run.sh --username=YOUR_USERNAME --password=YOUR_PASSWORD \
--tweet="Your tweet text here" [--headed]
Prints a single JSON line to stdout and exits 0 on success, exits 1 on failure:
{"success": true, "url": "https://twitter.com/username/status/..."}
{"success": false, "error": "Login failed: Invalid credentials"}
## Notes
- On a failed run, Playwright retains a screenshot and trace under `test-results/` for debugging.
These are gitignored and not committed, but they persist on disk.
- Twitter's UI changes frequently. If posting fails, check the selectors in `tests/twitter-post.spec.js`
and update them using: `npx playwright codegen https://twitter.com/i/flow/login`
- Twitter may show CAPTCHAs or 2FA prompts which will cause the automation to fail.
+12
View File
@@ -0,0 +1,12 @@
{
"name": "twitter-post-sidecar",
"private": true,
"version": "1.0.0",
"description": "Playwright acceptance test that logs into Twitter/X and creates a post, invoked via run.sh",
"scripts": {
"test": "playwright test"
},
"devDependencies": {
"@playwright/test": "^1.61.1"
}
}
@@ -0,0 +1,14 @@
/** @type {import('@playwright/test').PlaywrightTestConfig} */
const config = {
testDir: './tests',
fullyParallel: false,
workers: 1,
reporter: 'json',
use: {
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
};
export default config;
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
USERNAME=""
PASSWORD=""
TWEET=""
HEADLESS_FLAG="true"
for arg in "$@"; do
case "$arg" in
--username=*) USERNAME="${arg#*=}" ;;
--password=*) PASSWORD="${arg#*=}" ;;
--tweet=*) TWEET="${arg#*=}" ;;
--headed) HEADLESS_FLAG="false" ;;
*)
UNKNOWN_FLAG="${arg%%=*}"
echo "{\"success\": false, \"error\": \"Unknown argument: $UNKNOWN_FLAG\"}"
exit 1
;;
esac
done
MISSING=""
[ -z "$USERNAME" ] && MISSING="$MISSING --username"
[ -z "$PASSWORD" ] && MISSING="$MISSING --password"
[ -z "$TWEET" ] && MISSING="$MISSING --tweet"
if [ -n "$MISSING" ]; then
echo "{\"success\": false, \"error\": \"Missing required argument(s):$MISSING\"}"
exit 1
fi
export TWITTER_USERNAME="$USERNAME"
export TWITTER_PASSWORD="$PASSWORD"
export TWEET_TEXT="$TWEET"
export HEADLESS="$HEADLESS_FLAG"
set +e
npx playwright test tests/twitter-post.spec.js
PLAYWRIGHT_EXIT_CODE=$?
set -e
RESULT_FILE="test-results/result.json"
if [ -f "$RESULT_FILE" ]; then
cat "$RESULT_FILE"
echo
else
echo "{\"success\": false, \"error\": \"Test did not produce a result file\"}"
exit 1
fi
exit "$PLAYWRIGHT_EXIT_CODE"
@@ -0,0 +1,129 @@
// @ts-check
const { test, expect } = require('@playwright/test');
const fs = require('fs');
const path = require('path');
const RESULT_PATH = path.join(__dirname, '..', 'test-results', 'result.json');
function writeResult(result) {
fs.mkdirSync(path.dirname(RESULT_PATH), { recursive: true });
fs.writeFileSync(RESULT_PATH, JSON.stringify(result));
}
test.describe('twitter post', () => {
let outcome = { success: false, error: 'Test did not complete' };
let outcomeFinalized = false;
test.afterEach(async () => {
writeResult(outcome);
});
test('logs into Twitter and creates a post', async ({ page }) => {
const username = process.env.TWITTER_USERNAME;
const password = process.env.TWITTER_PASSWORD;
const tweet = process.env.TWEET_TEXT;
for (const [name, value] of Object.entries({
TWITTER_USERNAME: username,
TWITTER_PASSWORD: password,
TWEET_TEXT: tweet,
})) {
if (!value) {
outcome = { success: false, error: `Missing required environment variable: ${name}` };
outcomeFinalized = true;
throw new Error(outcome.error);
}
}
try {
await page.goto('https://twitter.com/i/flow/login');
// Wait for page to load
await page.waitForLoadState('networkidle');
// Check for login form
const usernameField = page.getByLabel(/username|phone|email/i).first();
if (!await usernameField.isVisible({ timeout: 10000 }).catch(() => false)) {
outcome = {
success: false,
error: 'Twitter login form not found — page structure may have changed',
};
outcomeFinalized = true;
throw new Error(outcome.error);
}
// Fill username and proceed
await usernameField.fill(username);
await usernameField.press('Enter');
// Wait for password field or next step
await page.waitForTimeout(2000);
const passwordField = page.getByLabel(/password/i).first();
if (await passwordField.isVisible({ timeout: 10000 }).catch(() => false)) {
await passwordField.fill(password);
await passwordField.press('Enter');
}
// Wait for login to complete
try {
await page.waitForURL('https://twitter.com/home', { timeout: 20000 });
} catch {
// Check if we're logged in by looking for tweet button
const tweetButton = page.getByRole('button', { name: /tweet|post/i }).first();
if (!await tweetButton.isVisible({ timeout: 5000 }).catch(() => false)) {
const errorText = await page.textContent('body').catch(() => '');
outcome = {
success: false,
error: errorText.includes('username') || errorText.includes('password')
? 'Invalid Twitter credentials'
: 'Twitter login did not complete — possible CAPTCHA, 2FA, or invalid credentials',
};
outcomeFinalized = true;
throw new Error(outcome.error);
}
}
// Click the tweet button
const tweetButton = page.getByRole('button', { name: /tweet|post/i }).first();
await tweetButton.click();
// Wait for tweet composer
const tweetTextarea = page.getByRole('textbox', { name: /what is happening/i }).first();
if (!await tweetTextarea.isVisible({ timeout: 10000 }).catch(() => false)) {
outcome = {
success: false,
error: 'Tweet composer not found — Twitter UI may have changed',
};
outcomeFinalized = true;
throw new Error(outcome.error);
}
// Fill tweet text
await tweetTextarea.fill(tweet);
// Click tweet/post button
const postButton = page.getByRole('button', { name: /tweet|post/i }).nth(1);
await expect(postButton).toBeEnabled({ timeout: 10000 });
await postButton.click();
// Wait for tweet to be posted
try {
await page.waitForURL(/(status|post)\//, { timeout: 20000 });
} catch {
// Check if tweet was posted by looking for success indicators
await page.waitForTimeout(3000);
}
// Get current URL (should be the tweet URL)
outcome = { success: true, url: page.url() };
outcomeFinalized = true;
expect(outcome.success).toBe(true);
} catch (err) {
if (!outcomeFinalized) {
outcome = { success: false, error: err instanceof Error ? err.message : String(err) };
}
throw err;
}
});
});