Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 40 additions & 4 deletions scripts/lib/twitter-brave-lock.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@
# One Brave CDP session at a time; pick a free debugging port (never steal :9224).
# shellcheck shell=bash

# True when no process holds flock on the lock file (safe to remove orphan file).
twitter_brave_lock_unheld() {
local lock="${1:?}"
[[ -f "${lock}" ]] || return 0
exec 8>>"${lock}"
if flock -n 8; then
flock -u 8
exec 8>&- || true
return 0
fi
exec 8>&- || true
return 1
}

twitter_brave_stale_lock_pid() {
local lock="${1:?}"
[[ -f "${lock}" ]] || return 1
Expand All @@ -16,28 +30,45 @@ twitter_brave_stale_lock_pid() {

twitter_brave_clear_stale_lock() {
local lock="${1:?}"
if [[ -f "${lock}" ]] && ! twitter_brave_stale_lock_pid "${lock}"; then
# Never unlink while another process holds flock (PID file can be empty mid-acquire).
if twitter_brave_lock_unheld "${lock}"; then
rm -f "${lock}"
return 0
fi
if [[ -f "${lock}" ]] && ! twitter_brave_stale_lock_pid "${lock}"; then
# Holder PID died but flock should be gone; only clear if flock is also free.
if twitter_brave_lock_unheld "${lock}"; then
rm -f "${lock}"
fi
fi
}

twitter_brave_acquire_lock() {
local run_root="${1:?run root}"
local wait_secs="${2:-180}"
local wait_secs="${2:-300}"
mkdir -p "${run_root}"
local lock="${run_root}/brave.lock"
local deadline=$((SECONDS + wait_secs))
while (( SECONDS < deadline )); do
twitter_brave_clear_stale_lock "${lock}"
exec 9>"${lock}"
exec 9<>"${lock}"
if flock -n 9; then
: >"${lock}"
echo "$$" >&9
return 0
fi
exec 9>&- || true
sleep 2
done
echo "another X Brave session holds ${lock}; wait or stop the other ship/pulse/status run" >&2
local holder=""
if [[ -f "${lock}" ]]; then
holder="$(tr -dc '0-9' <"${lock}" 2>/dev/null || true)"
fi
if [[ -n "${holder}" ]]; then
echo "another X Brave session holds ${lock} (pid ${holder}); wait or stop the other ship/pulse/status run" >&2
else
echo "another X Brave session holds ${lock}; wait or stop the other ship/pulse/status run" >&2
fi
return 1
}

Expand Down Expand Up @@ -72,3 +103,8 @@ twitter_brave_port_free() {
fi
return 0
}

twitter_brave_cdp_ready() {
local port="${1:?}"
curl -fsS --connect-timeout 0.3 "http://127.0.0.1:${port}/json/version" >/dev/null 2>&1
}
150 changes: 96 additions & 54 deletions scripts/post-twitter.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -429,20 +429,87 @@ async function resolveAfterPost(page, excludeIds, beforeId, toastHref) {
return null;
}

async function postReply(page, replyText, parent, excludeIds) {
await page.goto(parent.url, {
async function postReply(replyPage, replyText, parent, excludeIds) {
await replyPage.goto(parent.url, {
waitUntil: "domcontentloaded",
timeout: 60000,
});
await page.waitForTimeout(1000);
const btn = page.locator('[data-testid="reply"]').first();
await replyPage.waitForTimeout(1000);
if (looksLoggedOut(replyPage.url(), await replyPage.content())) {
fail("logged out on reply status page");
}
const btn = replyPage.locator('[data-testid="reply"]').first();
await btn.waitFor({ state: "visible", timeout: 20000 });
await btn.click();
await page.waitForTimeout(800);
await fillComposer(page, replyText);
const toastHref = await clickPost(page);
await replyPage.waitForTimeout(800);
await fillComposer(replyPage, replyText);
const toastHref = await clickPost(replyPage);
const skip = excludeIds.concat([parent.id]);
return resolveAfterPost(page, skip, parent.id, toastHref);
return resolveAfterPost(replyPage, skip, parent.id, toastHref);
}

/** One root tab; close stray CDP tabs from profile restore. */
async function prepareRootTab(context) {
const pages = context.pages();
const rootPage = pages[0] || (await context.newPage());
for (const page of pages.slice(1)) {
await page.close().catch(() => {});
}
return rootPage;
}

async function closeShipTabs(rootPage, replyPage) {
if (replyPage) {
await replyPage.close().catch(() => {});
}
if (rootPage) {
await rootPage.close().catch(() => {});
}
}

async function postRootTweet(rootPage, text, quoteId, excludeIds, beforeId) {
let rootToastHref = null;
if (quoteId) {
const scope = await openQuoteComposer(rootPage, quoteId);
await fillComposer(rootPage, text, scope);
rootToastHref = await clickPost(scope);
} else {
await clickProfilePost(rootPage);
if (looksLoggedOut(rootPage.url(), await rootPage.content())) {
fail("logged out on compose");
}
if (isStatusPermalink(rootPage.url())) {
fail(
"compose landed on a status permalink; refusing to type into that reply box"
);
}
const dialog = rootPage
.locator('[role="dialog"]')
.filter({ has: rootPage.locator('[data-testid="tweetTextarea_0"]') })
.first();
const scope = (await dialog.isVisible().catch(() => false))
? dialog
: rootPage;
await fillComposer(rootPage, text, scope);
rootToastHref = await clickPost(scope);
}

const found = await resolveAfterPost(
rootPage,
excludeIds,
beforeId,
rootToastHref
);
if (!found) {
const cap = await captureOverlay(rootPage, "resolve-miss");
fail(
`posted but could not resolve status (toast=${rootToastHref || "none"}; no newer own than ${beforeId}). screenshot=${relArtifact(cap.png)}`
);
}
if (!statusIdNewer(found.id, beforeId)) {
fail(`resolve picked non-newer id ${found.id} (before=${beforeId})`);
}
return found;
}

async function main() {
Expand All @@ -465,75 +532,45 @@ async function main() {

const browser = await chromium.connectOverCDP(cdpUrl);
const context = browser.contexts()[0] || (await browser.newContext());
const page = context.pages()[0] || (await context.newPage());
let rootPage = await prepareRootTab(context);
let replyPage = null;

try {
await goProfile(page);
const before = await latestOwnOnProfile(page, [], "");
await goProfile(rootPage);
const before = await latestOwnOnProfile(rootPage, [], "");
const beforeId = before && before.id ? before.id : "0";

if (inReplyToId) {
replyPage = await context.newPage();
const parent = {
id: inReplyToId,
url: `https://x.com/i/web/status/${inReplyToId}`,
};
const found = await postReply(page, text, parent, []);
const found = await postReply(replyPage, text, parent, []);
if (!found) {
const cap = await captureOverlay(page, "in-reply-resolve-miss");
const cap = await captureOverlay(replyPage, "in-reply-resolve-miss");
fail(
`reply posted but could not resolve status under ${inReplyToId}. screenshot=${relArtifact(cap.png)}`
);
}
if (!statusIdNewer(found.id, beforeId)) {
fail(`resolve picked non-newer id ${found.id} (before=${beforeId})`);
}
await closeShipTabs(rootPage, replyPage);
replyPage = null;
ok(found.id, found.url, `brave in-reply ok (parent=${inReplyToId})`, null);
return;
}

const excludeIds = quoteId ? [quoteId] : [];

let rootToastHref = null;
if (quoteId) {
const scope = await openQuoteComposer(page, quoteId);
await fillComposer(page, text, scope);
rootToastHref = await clickPost(scope);
} else {
await clickProfilePost(page);
if (looksLoggedOut(page.url(), await page.content())) {
fail("logged out on compose");
}
if (isStatusPermalink(page.url())) {
fail(
"compose landed on a status permalink; refusing to type into that reply box"
);
}
const dialog = page
.locator('[role="dialog"]')
.filter({ has: page.locator('[data-testid="tweetTextarea_0"]') })
.first();
const scope = (await dialog.isVisible().catch(() => false))
? dialog
: page;
await fillComposer(page, text, scope);
rootToastHref = await clickPost(scope);
}

const found = await resolveAfterPost(
page,
const found = await postRootTweet(
rootPage,
text,
quoteId,
excludeIds,
beforeId,
rootToastHref
beforeId
);
if (!found) {
const cap = await captureOverlay(page, "resolve-miss");
fail(
`posted but could not resolve status (toast=${rootToastHref || "none"}; no newer own than ${beforeId}). screenshot=${relArtifact(cap.png)}`
);
}
if (!statusIdNewer(found.id, beforeId)) {
fail(`resolve picked non-newer id ${found.id} (before=${beforeId})`);
}

let replyFound = null;
if (replyFile) {
Expand All @@ -546,21 +583,26 @@ async function main() {
if (!replyText) {
fail("overflow reply file empty (root would ship without tags/URL)");
}
replyFound = await postReply(page, replyText, found, excludeIds);
replyPage = await context.newPage();
replyFound = await postReply(replyPage, replyText, found, excludeIds);
if (!replyFound) {
const cap = await captureOverlay(page, "reply-resolve-miss");
const cap = await captureOverlay(replyPage, "reply-resolve-miss");
fail(
`root ${found.id} live but overflow reply did not resolve. screenshot=${relArtifact(cap.png)}`
);
}
}
await closeShipTabs(rootPage, replyPage);
rootPage = null;
replyPage = null;
ok(
found.id,
found.url,
replyFound ? "brave post+reply ok" : "brave post ok",
replyFound
);
} catch (e) {
await closeShipTabs(rootPage, replyPage).catch(() => {});
fail(e && e.message ? e.message : String(e));
}
// Do not call Playwright browser teardown here: on CDP that kills Brave while
Expand Down
56 changes: 41 additions & 15 deletions scripts/post-twitter.sh
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ RUN_ROOT="${ITCY_TWITTER_RUN_DIR:-${ROOT}/pw/profile-x-run}"
. "${ROOT}/scripts/lib/twitter-brave-lock.sh"
twitter_brave_acquire_lock "${RUN_ROOT}" || exit 1
WORK="${RUN_ROOT}/run-$$"
# Prefer explicit env; otherwise pick a free port (never attach to a stale :9224).
CDP_PORT="$(twitter_brave_pick_cdp_port "${ITCY_TWITTER_CDP_PORT:-}")"
BRAVE_PID=""
CDP_PORT=""
BRAVE_LOG="/tmp/itcy-twitter-brave-$$.log"

cleanup() {
if [[ -n "${BRAVE_PID}" ]] && kill -0 "${BRAVE_PID}" 2>/dev/null; then
Expand Down Expand Up @@ -163,36 +163,62 @@ fi

# Headed by default (X is hostile to headless). Override with ITCY_TWITTER_HEADLESS=1.
HEADLESS="${ITCY_TWITTER_HEADLESS:-0}"
BRAVE_ARGS=(
BRAVE_ARGS_BASE=(
--user-data-dir="${WORK}"
--remote-debugging-port="${CDP_PORT}"
--no-first-run
--no-default-browser-check
--disable-session-crashed-bubble
--disable-blink-features=AutomationControlled
--no-startup-window
)
if [[ "${HEADLESS}" == "1" ]]; then
BRAVE_ARGS+=(--headless=new)
BRAVE_ARGS_BASE+=(--headless=new)
fi

nohup "${BROWSER_BIN}" "${BRAVE_ARGS[@]}" \
>/tmp/itcy-twitter-brave-$$.log 2>&1 &
BRAVE_PID=$!
PREFERRED_PORT="${ITCY_TWITTER_CDP_PORT:-}"
PORT_CANDIDATES=()
if [[ -n "${PREFERRED_PORT}" ]]; then
PORT_CANDIDATES+=("${PREFERRED_PORT}")
fi
for p in $(seq 9230 9299); do
PORT_CANDIDATES+=("${p}")
done

ready=0
for _ in $(seq 1 50); do
if ! kill -0 "${BRAVE_PID}" 2>/dev/null; then
break
for try_port in "${PORT_CANDIDATES[@]}"; do
if ! twitter_brave_port_free "${try_port}"; then
continue
fi
if curl -fsS "http://127.0.0.1:${CDP_PORT}/json/version" >/dev/null 2>&1; then
ready=1
: >"${BRAVE_LOG}"
nohup "${BROWSER_BIN}" \
"${BRAVE_ARGS_BASE[@]}" \
--remote-debugging-port="${try_port}" \
>"${BRAVE_LOG}" 2>&1 &
BRAVE_PID=$!
for _ in $(seq 1 50); do
if ! kill -0 "${BRAVE_PID}" 2>/dev/null; then
break
fi
if twitter_brave_cdp_ready "${try_port}"; then
ready=1
CDP_PORT="${try_port}"
break
fi
sleep 0.2
done
if [[ "${ready}" == "1" ]]; then
break
fi
sleep 0.2
if [[ -n "${BRAVE_PID}" ]] && kill -0 "${BRAVE_PID}" 2>/dev/null; then
kill -TERM "${BRAVE_PID}" 2>/dev/null || true
fi
BRAVE_PID=""
pkill -TERM -f "user-data-dir=${WORK}" 2>/dev/null || true
sleep 0.3
done

if [[ "${ready}" != "1" ]]; then
echo "Brave CDP not ready on :${CDP_PORT} (see /tmp/itcy-twitter-brave-$$.log)" >&2
echo "Brave CDP not ready on any port 9230-9299 (see ${BRAVE_LOG})" >&2
exit 1
fi

Expand Down
10 changes: 5 additions & 5 deletions scripts/x-ship-resolve.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,11 @@ export function detectPostRejectReason(pageText) {
const body = String(pageText || "").replace(/\s+/g, " ").trim();
if (!body) return null;
const patterns = [
/It looks like you already said that![^.]*\./i,
/Wait a little while before you post again[^.]*\./i,
/Something went wrong\.?\s*Try again[^.]*\./i,
/You are over the character limit[^.]*\./i,
/Whoops!\s*You already said that[^.]*\./i,
/It looks like you already said that/i,
/Wait a little while before you post again/i,
/Something went wrong\.?\s*Try again/i,
/You are over the character limit/i,
/Whoops!\s*You already said that/i,
];
for (const re of patterns) {
const m = body.match(re);
Expand Down
Loading
Loading