Merge pull request #173 from pejmanjohn/contrib/mvanhorn-last30days-skill-44-bird-sweet-cookie-runtime
last30days: lazy-load sweet-cookie so vendored Bird works on fresh installs
This commit is contained in:
+33
-4
@@ -2,11 +2,13 @@
|
|||||||
* Browser cookie extraction for Twitter authentication.
|
* Browser cookie extraction for Twitter authentication.
|
||||||
* Delegates to @steipete/sweet-cookie for Safari/Chrome/Firefox reads.
|
* Delegates to @steipete/sweet-cookie for Safari/Chrome/Firefox reads.
|
||||||
*/
|
*/
|
||||||
import { getCookies } from '@steipete/sweet-cookie';
|
|
||||||
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'];
|
const TWITTER_COOKIE_NAMES = ['auth_token', 'ct0'];
|
||||||
const TWITTER_URL = 'https://x.com/';
|
const TWITTER_URL = 'https://x.com/';
|
||||||
const TWITTER_ORIGINS = ['https://x.com/', 'https://twitter.com/'];
|
const TWITTER_ORIGINS = ['https://x.com/', 'https://twitter.com/'];
|
||||||
const DEFAULT_COOKIE_TIMEOUT_MS = 30_000;
|
const DEFAULT_COOKIE_TIMEOUT_MS = 30_000;
|
||||||
|
async function loadSweetCookie() {
|
||||||
|
return import('@steipete/sweet-cookie');
|
||||||
|
}
|
||||||
function normalizeValue(value) {
|
function normalizeValue(value) {
|
||||||
if (typeof value !== 'string') {
|
if (typeof value !== 'string') {
|
||||||
return null;
|
return null;
|
||||||
@@ -79,6 +81,7 @@ function pickCookieValue(cookies, name) {
|
|||||||
async function readTwitterCookiesFromBrowser(options) {
|
async function readTwitterCookiesFromBrowser(options) {
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
const out = buildEmpty();
|
const out = buildEmpty();
|
||||||
|
const { getCookies } = options;
|
||||||
const { cookies, warnings: providerWarnings } = await getCookies({
|
const { cookies, warnings: providerWarnings } = await getCookies({
|
||||||
url: TWITTER_URL,
|
url: TWITTER_URL,
|
||||||
origins: TWITTER_ORIGINS,
|
origins: TWITTER_ORIGINS,
|
||||||
@@ -114,14 +117,21 @@ async function readTwitterCookiesFromBrowser(options) {
|
|||||||
}
|
}
|
||||||
return { cookies: out, warnings };
|
return { cookies: out, warnings };
|
||||||
}
|
}
|
||||||
|
async function extractCookiesFromBrowser(options) {
|
||||||
|
const { getCookies } = await loadSweetCookie();
|
||||||
|
return readTwitterCookiesFromBrowser({
|
||||||
|
getCookies,
|
||||||
|
...options,
|
||||||
|
});
|
||||||
|
}
|
||||||
export async function extractCookiesFromSafari() {
|
export async function extractCookiesFromSafari() {
|
||||||
return readTwitterCookiesFromBrowser({ source: 'safari' });
|
return extractCookiesFromBrowser({ source: 'safari' });
|
||||||
}
|
}
|
||||||
export async function extractCookiesFromChrome(profile) {
|
export async function extractCookiesFromChrome(profile) {
|
||||||
return readTwitterCookiesFromBrowser({ source: 'chrome', chromeProfile: profile });
|
return extractCookiesFromBrowser({ source: 'chrome', chromeProfile: profile });
|
||||||
}
|
}
|
||||||
export async function extractCookiesFromFirefox(profile) {
|
export async function extractCookiesFromFirefox(profile) {
|
||||||
return readTwitterCookiesFromBrowser({ source: 'firefox', firefoxProfile: profile });
|
return extractCookiesFromBrowser({ source: 'firefox', firefoxProfile: profile });
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Resolve Twitter credentials from multiple sources.
|
* Resolve Twitter credentials from multiple sources.
|
||||||
@@ -165,8 +175,27 @@ export async function resolveCredentials(options) {
|
|||||||
return { cookies, warnings };
|
return { cookies, warnings };
|
||||||
}
|
}
|
||||||
const sourcesToTry = resolveSources(options.cookieSource);
|
const sourcesToTry = resolveSources(options.cookieSource);
|
||||||
|
let getCookies;
|
||||||
|
try {
|
||||||
|
({ getCookies } = await loadSweetCookie());
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
if (error?.code !== 'ERR_MODULE_NOT_FOUND' ||
|
||||||
|
!String(error.message ?? '').includes('@steipete/sweet-cookie')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
warnings.push('Browser cookie lookup unavailable because vendored dependency @steipete/sweet-cookie is not installed.');
|
||||||
|
if (!cookies.authToken) {
|
||||||
|
warnings.push('Missing auth_token - provide via --auth-token, AUTH_TOKEN env var, or login to x.com in Safari/Chrome/Firefox');
|
||||||
|
}
|
||||||
|
if (!cookies.ct0) {
|
||||||
|
warnings.push('Missing ct0 - provide via --ct0, CT0 env var, or login to x.com in Safari/Chrome/Firefox');
|
||||||
|
}
|
||||||
|
return { cookies, warnings };
|
||||||
|
}
|
||||||
for (const source of sourcesToTry) {
|
for (const source of sourcesToTry) {
|
||||||
const res = await readTwitterCookiesFromBrowser({
|
const res = await readTwitterCookiesFromBrowser({
|
||||||
|
getCookies,
|
||||||
source,
|
source,
|
||||||
chromeProfile: options.chromeProfile,
|
chromeProfile: options.chromeProfile,
|
||||||
firefoxProfile: options.firefoxProfile,
|
firefoxProfile: options.firefoxProfile,
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import textwrap
|
||||||
import unittest
|
import unittest
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -7,6 +12,10 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts"))
|
|||||||
from lib.bird_x import parse_bird_response
|
from lib.bird_x import parse_bird_response
|
||||||
|
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
VENDORED_BIRD = REPO_ROOT / "scripts" / "lib" / "vendor" / "bird-search" / "bird-search.mjs"
|
||||||
|
|
||||||
|
|
||||||
class TestBirdXEngagementZero(unittest.TestCase):
|
class TestBirdXEngagementZero(unittest.TestCase):
|
||||||
def test_zero_likes_preserved(self):
|
def test_zero_likes_preserved(self):
|
||||||
tweets = [
|
tweets = [
|
||||||
@@ -22,6 +31,140 @@ class TestBirdXEngagementZero(unittest.TestCase):
|
|||||||
self.assertEqual(0, items[0]["engagement"]["likes"])
|
self.assertEqual(0, items[0]["engagement"]["likes"])
|
||||||
self.assertEqual(5, items[0]["engagement"]["reposts"])
|
self.assertEqual(5, items[0]["engagement"]["reposts"])
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(shutil.which("node"), "node is required for vendored Bird tests")
|
||||||
|
class TestVendoredBirdRuntime(unittest.TestCase):
|
||||||
|
def test_check_uses_env_credentials_without_browser_cookie_dependency(self):
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["AUTH_TOKEN"] = "dummy-auth"
|
||||||
|
env["CT0"] = "dummy-ct0"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", str(VENDORED_BIRD), "--check"],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
self.assertTrue(payload["authenticated"])
|
||||||
|
self.assertEqual("env AUTH_TOKEN", payload["source"])
|
||||||
|
|
||||||
|
def test_check_with_browser_lookup_disabled_returns_json_warnings(self):
|
||||||
|
env = os.environ.copy()
|
||||||
|
env.pop("AUTH_TOKEN", None)
|
||||||
|
env.pop("CT0", None)
|
||||||
|
env["BIRD_DISABLE_BROWSER_COOKIES"] = "1"
|
||||||
|
|
||||||
|
result = subprocess.run(
|
||||||
|
["node", str(VENDORED_BIRD), "--check"],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
env=env,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, result.returncode, result.stderr)
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
self.assertFalse(payload["authenticated"])
|
||||||
|
self.assertTrue(payload["warnings"])
|
||||||
|
self.assertIn("Missing auth_token", " ".join(payload["warnings"]))
|
||||||
|
|
||||||
|
def test_browser_cookie_helpers_lazy_load_sweet_cookie(self):
|
||||||
|
sweet_cookie_dir = (
|
||||||
|
REPO_ROOT
|
||||||
|
/ "scripts"
|
||||||
|
/ "lib"
|
||||||
|
/ "vendor"
|
||||||
|
/ "bird-search"
|
||||||
|
/ "lib"
|
||||||
|
/ "node_modules"
|
||||||
|
/ "@steipete"
|
||||||
|
/ "sweet-cookie"
|
||||||
|
)
|
||||||
|
if sweet_cookie_dir.exists():
|
||||||
|
self.skipTest("vendored sweet-cookie test stub already exists")
|
||||||
|
|
||||||
|
sweet_cookie_dir.mkdir(parents=True)
|
||||||
|
(sweet_cookie_dir / "package.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "@steipete/sweet-cookie",
|
||||||
|
"type": "module",
|
||||||
|
"exports": "./index.js",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(sweet_cookie_dir / "index.js").write_text(
|
||||||
|
textwrap.dedent(
|
||||||
|
"""
|
||||||
|
export async function getCookies(options) {
|
||||||
|
const browser = options.browsers?.[0] ?? "unknown";
|
||||||
|
return {
|
||||||
|
cookies: [
|
||||||
|
{ name: "auth_token", value: `${browser}-auth`, domain: "x.com" },
|
||||||
|
{ name: "ct0", value: `${browser}-ct0`, domain: "x.com" },
|
||||||
|
],
|
||||||
|
warnings: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[
|
||||||
|
"node",
|
||||||
|
"--input-type=module",
|
||||||
|
"-e",
|
||||||
|
textwrap.dedent(
|
||||||
|
"""
|
||||||
|
import {
|
||||||
|
extractCookiesFromSafari,
|
||||||
|
extractCookiesFromChrome,
|
||||||
|
extractCookiesFromFirefox,
|
||||||
|
} from "./scripts/lib/vendor/bird-search/lib/cookies.js";
|
||||||
|
|
||||||
|
const payload = await Promise.all([
|
||||||
|
extractCookiesFromSafari(),
|
||||||
|
extractCookiesFromChrome("Profile 1"),
|
||||||
|
extractCookiesFromFirefox("default-release"),
|
||||||
|
]);
|
||||||
|
process.stdout.write(JSON.stringify(payload));
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
],
|
||||||
|
cwd=REPO_ROOT,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(0, result.returncode, result.stderr)
|
||||||
|
payload = json.loads(result.stdout)
|
||||||
|
self.assertEqual("Safari", payload[0]["cookies"]["source"])
|
||||||
|
self.assertEqual('Chrome profile "Profile 1"', payload[1]["cookies"]["source"])
|
||||||
|
self.assertEqual(
|
||||||
|
'Firefox profile "default-release"', payload[2]["cookies"]["source"]
|
||||||
|
)
|
||||||
|
self.assertEqual("safari-auth", payload[0]["cookies"]["authToken"])
|
||||||
|
self.assertEqual("chrome-auth", payload[1]["cookies"]["authToken"])
|
||||||
|
self.assertEqual("firefox-auth", payload[2]["cookies"]["authToken"])
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(sweet_cookie_dir, ignore_errors=True)
|
||||||
|
for path in [sweet_cookie_dir.parent, sweet_cookie_dir.parent.parent]:
|
||||||
|
try:
|
||||||
|
path.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
def test_none_likes_when_missing(self):
|
def test_none_likes_when_missing(self):
|
||||||
tweets = [
|
tweets = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user