standout 0.5.29 → 0.5.31
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser-session.d.ts +3 -0
- package/dist/browser-session.js +227 -0
- package/dist/cli.js +7 -1
- package/dist/gather.d.ts +1 -1
- package/dist/gather.js +26 -1
- package/dist/identity.d.ts +8 -0
- package/dist/identity.js +29 -0
- package/dist/linkedin-scrape.d.ts +2 -0
- package/dist/linkedin-scrape.js +56 -0
- package/dist/twitter-scrape.js +32 -251
- package/dist/wrapped-client.d.ts +6 -0
- package/dist/wrapped-client.js +7 -1
- package/package.json +1 -1
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { chromium } from "playwright-core";
|
|
2
|
+
import { existsSync, readdirSync, statSync, mkdtempSync, mkdirSync, copyFileSync, rmSync, readFileSync, } from "fs";
|
|
3
|
+
import { homedir, platform, tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { spawn } from "child_process";
|
|
6
|
+
const DEBUG_PORT_WAIT_MS = 10000;
|
|
7
|
+
export const NAV_TIMEOUT_MS = 20000;
|
|
8
|
+
const TOTAL_TIMEOUT_MS = 40000;
|
|
9
|
+
function wait(ms) {
|
|
10
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
11
|
+
}
|
|
12
|
+
function findChromeExecutable() {
|
|
13
|
+
const os = platform();
|
|
14
|
+
const candidates = os === "darwin"
|
|
15
|
+
? [
|
|
16
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
17
|
+
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
|
18
|
+
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
19
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
20
|
+
]
|
|
21
|
+
: os === "linux"
|
|
22
|
+
? [
|
|
23
|
+
"/usr/bin/google-chrome",
|
|
24
|
+
"/usr/bin/google-chrome-stable",
|
|
25
|
+
"/usr/bin/chromium",
|
|
26
|
+
"/usr/bin/chromium-browser",
|
|
27
|
+
"/snap/bin/chromium",
|
|
28
|
+
]
|
|
29
|
+
: os === "win32"
|
|
30
|
+
? [
|
|
31
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
32
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
33
|
+
]
|
|
34
|
+
: [];
|
|
35
|
+
for (const path of candidates) {
|
|
36
|
+
if (existsSync(path))
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
function findChromeProfileRoot() {
|
|
42
|
+
const os = platform();
|
|
43
|
+
const home = homedir();
|
|
44
|
+
const candidates = os === "darwin"
|
|
45
|
+
? [
|
|
46
|
+
join(home, "Library", "Application Support", "Google", "Chrome"),
|
|
47
|
+
join(home, "Library", "Application Support", "Chromium"),
|
|
48
|
+
]
|
|
49
|
+
: os === "linux"
|
|
50
|
+
? [
|
|
51
|
+
join(home, ".config", "google-chrome"),
|
|
52
|
+
join(home, ".config", "chromium"),
|
|
53
|
+
]
|
|
54
|
+
: os === "win32"
|
|
55
|
+
? [join(home, "AppData", "Local", "Google", "Chrome", "User Data")]
|
|
56
|
+
: [];
|
|
57
|
+
for (const path of candidates) {
|
|
58
|
+
if (existsSync(path) && existsSync(join(path, "Local State")))
|
|
59
|
+
return path;
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
function listProfilesByMtime(profileRoot) {
|
|
64
|
+
try {
|
|
65
|
+
return readdirSync(profileRoot)
|
|
66
|
+
.filter((d) => existsSync(join(profileRoot, d, "Cookies")))
|
|
67
|
+
.map((d) => ({
|
|
68
|
+
name: d,
|
|
69
|
+
mtime: statSync(join(profileRoot, d, "Cookies")).mtimeMs,
|
|
70
|
+
}))
|
|
71
|
+
.sort((a, b) => b.mtime - a.mtime)
|
|
72
|
+
.map((p) => p.name);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function prepareProfileCopy(profileRoot, profileName) {
|
|
79
|
+
try {
|
|
80
|
+
const tempRoot = mkdtempSync(join(tmpdir(), "standout-chrome-"));
|
|
81
|
+
mkdirSync(join(tempRoot, "Default"), { recursive: true });
|
|
82
|
+
const localState = join(profileRoot, "Local State");
|
|
83
|
+
if (existsSync(localState)) {
|
|
84
|
+
copyFileSync(localState, join(tempRoot, "Local State"));
|
|
85
|
+
}
|
|
86
|
+
const sourceDir = join(profileRoot, profileName);
|
|
87
|
+
for (const name of ["Cookies", "Cookies-journal", "Preferences"]) {
|
|
88
|
+
const src = join(sourceDir, name);
|
|
89
|
+
if (existsSync(src)) {
|
|
90
|
+
copyFileSync(src, join(tempRoot, "Default", name));
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return tempRoot;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function waitForDebugPort(userDataDir, timeoutMs) {
|
|
100
|
+
const deadline = Date.now() + timeoutMs;
|
|
101
|
+
const portFile = join(userDataDir, "DevToolsActivePort");
|
|
102
|
+
while (Date.now() < deadline) {
|
|
103
|
+
if (existsSync(portFile)) {
|
|
104
|
+
try {
|
|
105
|
+
const [port] = readFileSync(portFile, "utf-8").trim().split("\n");
|
|
106
|
+
if (port && /^\d+$/.test(port))
|
|
107
|
+
return parseInt(port, 10);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// keep polling
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
await wait(200);
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
async function spawnChrome(executablePath, userDataDir) {
|
|
118
|
+
const os = platform();
|
|
119
|
+
const args = [
|
|
120
|
+
"--headless=new",
|
|
121
|
+
`--user-data-dir=${userDataDir}`,
|
|
122
|
+
"--remote-debugging-port=0",
|
|
123
|
+
"--no-first-run",
|
|
124
|
+
"--no-default-browser-check",
|
|
125
|
+
"--disable-sync",
|
|
126
|
+
"--disable-features=AutofillServerCommunication",
|
|
127
|
+
];
|
|
128
|
+
const cleanupFns = [];
|
|
129
|
+
if (os === "darwin") {
|
|
130
|
+
// Use `open -n -a` to force a new Chrome process instance on macOS
|
|
131
|
+
// (direct binary launch gets intercepted by the singleton running Chrome).
|
|
132
|
+
const appBundle = executablePath.replace(/\.app\/Contents\/MacOS\/.*$/, ".app");
|
|
133
|
+
const child = spawn("open", ["-n", "-a", appBundle, "--args", ...args], {
|
|
134
|
+
detached: true,
|
|
135
|
+
stdio: "ignore",
|
|
136
|
+
});
|
|
137
|
+
child.unref();
|
|
138
|
+
cleanupFns.push(() => {
|
|
139
|
+
try {
|
|
140
|
+
spawn("pkill", ["-f", `user-data-dir=${userDataDir}`]);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// skip
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
const child = spawn(executablePath, args, {
|
|
149
|
+
detached: true,
|
|
150
|
+
stdio: "ignore",
|
|
151
|
+
});
|
|
152
|
+
child.unref();
|
|
153
|
+
cleanupFns.push(() => {
|
|
154
|
+
try {
|
|
155
|
+
if (child.pid)
|
|
156
|
+
process.kill(child.pid);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
// skip
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
const port = await waitForDebugPort(userDataDir, DEBUG_PORT_WAIT_MS);
|
|
164
|
+
return {
|
|
165
|
+
port,
|
|
166
|
+
cleanup: () => cleanupFns.forEach((fn) => fn()),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
async function withTimeout(promise, ms) {
|
|
170
|
+
return Promise.race([
|
|
171
|
+
promise,
|
|
172
|
+
new Promise((resolve) => setTimeout(() => resolve(null), ms)),
|
|
173
|
+
]);
|
|
174
|
+
}
|
|
175
|
+
async function withProfileContext(executablePath, profileRoot, profileName, extract) {
|
|
176
|
+
const tempProfile = prepareProfileCopy(profileRoot, profileName);
|
|
177
|
+
if (!tempProfile)
|
|
178
|
+
return null;
|
|
179
|
+
const { port, cleanup } = await spawnChrome(executablePath, tempProfile);
|
|
180
|
+
let browser = null;
|
|
181
|
+
try {
|
|
182
|
+
if (!port)
|
|
183
|
+
return null;
|
|
184
|
+
browser = await chromium.connectOverCDP(`http://localhost:${port}`);
|
|
185
|
+
const contexts = browser.contexts();
|
|
186
|
+
const context = contexts[0] ?? (await browser.newContext());
|
|
187
|
+
return await extract(context);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
finally {
|
|
193
|
+
if (browser)
|
|
194
|
+
await browser.close().catch(() => undefined);
|
|
195
|
+
cleanup();
|
|
196
|
+
await wait(500);
|
|
197
|
+
try {
|
|
198
|
+
rmSync(tempProfile, { recursive: true, force: true });
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// skip
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Headless, silent read of the user's logged-in Chrome session. Copies each
|
|
206
|
+
// Chrome profile (newest-cookie first) into a throwaway dir, opens it headless,
|
|
207
|
+
// and hands the browser context to `extract`. Returns the first non-null result;
|
|
208
|
+
// any failure (no Chrome, no session, checkpoint, timeout) resolves to null.
|
|
209
|
+
export async function scrapeFromChromeSession(extract) {
|
|
210
|
+
const executablePath = findChromeExecutable();
|
|
211
|
+
if (!executablePath)
|
|
212
|
+
return null;
|
|
213
|
+
const profileRoot = findChromeProfileRoot();
|
|
214
|
+
if (!profileRoot)
|
|
215
|
+
return null;
|
|
216
|
+
const profiles = listProfilesByMtime(profileRoot);
|
|
217
|
+
if (profiles.length === 0)
|
|
218
|
+
return null;
|
|
219
|
+
return withTimeout((async () => {
|
|
220
|
+
for (const profile of profiles) {
|
|
221
|
+
const result = await withProfileContext(executablePath, profileRoot, profile, extract).catch(() => null);
|
|
222
|
+
if (result)
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
return null;
|
|
226
|
+
})(), TOTAL_TIMEOUT_MS);
|
|
227
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -136,7 +136,9 @@ async function runAgent(jobId) {
|
|
|
136
136
|
console.log(" │ are you really tokenmaxxing? │");
|
|
137
137
|
console.log(" │ Powered by Standout (YC P26) │");
|
|
138
138
|
console.log(" └────────────────────────────────┘\n");
|
|
139
|
-
|
|
139
|
+
// Only frame the token as a job in the application flow. Otherwise it may be a
|
|
140
|
+
// leaderboard group (`npx standout stanford`), confirmed after the wrapped runs.
|
|
141
|
+
if (jobId && profileChatEnabled()) {
|
|
140
142
|
console.log(` Applying to: ${STANDOUT_API_URL}/jobs/${jobId}\n`);
|
|
141
143
|
}
|
|
142
144
|
// LLM calls are proxied through Standout — users don't need an Anthropic key.
|
|
@@ -175,6 +177,7 @@ async function runAgent(jobId) {
|
|
|
175
177
|
const wrapped = await createWrapped({
|
|
176
178
|
profile: prefetched,
|
|
177
179
|
submissionId: null,
|
|
180
|
+
group: jobId ?? null,
|
|
178
181
|
});
|
|
179
182
|
return { wrapped, prefetched };
|
|
180
183
|
}
|
|
@@ -196,6 +199,9 @@ async function runAgent(jobId) {
|
|
|
196
199
|
let wrappedId = wrapped?.id ?? null;
|
|
197
200
|
if (wrapped) {
|
|
198
201
|
await renderWrappedAll(wrapped.view);
|
|
202
|
+
if (wrapped.group) {
|
|
203
|
+
process.stderr.write(` you're on the ${wrapped.group.label} leaderboard — see where you rank.\n\n`);
|
|
204
|
+
}
|
|
199
205
|
await maybeShareWrapped(wrapped);
|
|
200
206
|
}
|
|
201
207
|
else {
|
package/dist/gather.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ export interface GatheredProfile {
|
|
|
25
25
|
};
|
|
26
26
|
linkedin: {
|
|
27
27
|
url: string | null;
|
|
28
|
-
url_resolution_source: "email" | "ai_search" | null;
|
|
28
|
+
url_resolution_source: "email" | "ai_search" | "chrome_session" | null;
|
|
29
29
|
profile: Record<string, unknown> | null;
|
|
30
30
|
company_enrichments: Record<string, Record<string, unknown>>;
|
|
31
31
|
};
|
package/dist/gather.js
CHANGED
|
@@ -6,6 +6,8 @@ import { STANDOUT_API_URL } from "./api.js";
|
|
|
6
6
|
import { gatherAiUsage } from "./ai-usage.js";
|
|
7
7
|
import { gatherDevEnvironment } from "./dev-env.js";
|
|
8
8
|
import { scrapeTwitterHandle } from "./twitter-scrape.js";
|
|
9
|
+
import { scrapeLinkedinUrl } from "./linkedin-scrape.js";
|
|
10
|
+
import { resolveDisplayName } from "./identity.js";
|
|
9
11
|
import { redactSecrets } from "./redact.js";
|
|
10
12
|
export function emptyGatheredProfile(readiness = {
|
|
11
13
|
usage_stats_status: "unavailable",
|
|
@@ -627,6 +629,22 @@ export async function gather(options = {}) {
|
|
|
627
629
|
linkedinResolutionSource = "ai_search";
|
|
628
630
|
}
|
|
629
631
|
}
|
|
632
|
+
// Fallback: read a logged-in LinkedIn session from the local Chrome profile
|
|
633
|
+
// (headless, silent). Same technique as the X handle scrape — the only anchor
|
|
634
|
+
// for users with no git email/name and no GitHub remote.
|
|
635
|
+
if (!linkedinUrl) {
|
|
636
|
+
gatherLog(options, "Checking for a LinkedIn session in Chrome...\n");
|
|
637
|
+
try {
|
|
638
|
+
const scraped = await scrapeLinkedinUrl();
|
|
639
|
+
if (scraped) {
|
|
640
|
+
linkedinUrl = scraped;
|
|
641
|
+
linkedinResolutionSource = "chrome_session";
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
catch {
|
|
645
|
+
// silent
|
|
646
|
+
}
|
|
647
|
+
}
|
|
630
648
|
if (linkedinUrl) {
|
|
631
649
|
linkedinProfile = await enrichApi("linkedin_profile", linkedinUrl);
|
|
632
650
|
// Company enrichment
|
|
@@ -821,6 +839,13 @@ export async function gather(options = {}) {
|
|
|
821
839
|
githubEnrichPromise,
|
|
822
840
|
]);
|
|
823
841
|
gatherLog(options, "Done gathering.\n");
|
|
842
|
+
const resolvedName = resolveDisplayName({
|
|
843
|
+
gitName: name,
|
|
844
|
+
githubName: ghProfile?.name,
|
|
845
|
+
linkedinProfile: linkedinProfile,
|
|
846
|
+
githubUsername,
|
|
847
|
+
twitterHandle,
|
|
848
|
+
});
|
|
824
849
|
return {
|
|
825
850
|
readiness: {
|
|
826
851
|
usage_stats_status: hasAnyUsage(aiUsage) ? "ready" : "unavailable",
|
|
@@ -828,7 +853,7 @@ export async function gather(options = {}) {
|
|
|
828
853
|
enrichment_status: "ready",
|
|
829
854
|
},
|
|
830
855
|
identity: {
|
|
831
|
-
name:
|
|
856
|
+
name: resolvedName,
|
|
832
857
|
email: email || null,
|
|
833
858
|
github_username: githubUsername,
|
|
834
859
|
twitter_username: twitterHandle,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export declare function linkedinFullName(profile: Record<string, unknown> | null | undefined): string | null;
|
|
2
|
+
export declare function resolveDisplayName(opts: {
|
|
3
|
+
gitName: string | null | undefined;
|
|
4
|
+
githubName: unknown;
|
|
5
|
+
linkedinProfile: Record<string, unknown> | null | undefined;
|
|
6
|
+
githubUsername: string | null | undefined;
|
|
7
|
+
twitterHandle: string | null | undefined;
|
|
8
|
+
}): string | null;
|
package/dist/identity.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function cleanName(value) {
|
|
2
|
+
return typeof value === "string" && value.trim() ? value.trim() : null;
|
|
3
|
+
}
|
|
4
|
+
// LinkedIn enrichment exposes the display name as `full_name`, falling back to
|
|
5
|
+
// first/last when only those are present.
|
|
6
|
+
export function linkedinFullName(profile) {
|
|
7
|
+
if (!profile)
|
|
8
|
+
return null;
|
|
9
|
+
const full = cleanName(profile.full_name);
|
|
10
|
+
if (full)
|
|
11
|
+
return full;
|
|
12
|
+
const joined = [cleanName(profile.first_name), cleanName(profile.last_name)]
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.join(" ")
|
|
15
|
+
.trim();
|
|
16
|
+
return joined || null;
|
|
17
|
+
}
|
|
18
|
+
// Identity name fallback chain. The self-set git name stays primary; when it's
|
|
19
|
+
// missing we fall back to real names (GitHub, then LinkedIn) and finally to
|
|
20
|
+
// handles (GitHub, then Twitter) so a wrapped is never anonymous when any
|
|
21
|
+
// identity signal exists.
|
|
22
|
+
export function resolveDisplayName(opts) {
|
|
23
|
+
return (cleanName(opts.gitName) ??
|
|
24
|
+
cleanName(opts.githubName) ??
|
|
25
|
+
linkedinFullName(opts.linkedinProfile) ??
|
|
26
|
+
cleanName(opts.githubUsername) ??
|
|
27
|
+
cleanName(opts.twitterHandle) ??
|
|
28
|
+
null);
|
|
29
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { NAV_TIMEOUT_MS, scrapeFromChromeSession } from "./browser-session.js";
|
|
2
|
+
// LinkedIn bounces unauthenticated / new-device sessions to one of these.
|
|
3
|
+
const BLOCKED_RE = /\/(login|uas\/login|checkpoint|authwall)/i;
|
|
4
|
+
// Reduce any LinkedIn profile URL to the canonical vanity form, or null if it
|
|
5
|
+
// isn't a real member profile (e.g. the `/in/me` self-redirect target itself).
|
|
6
|
+
export function normalizeLinkedinUrl(raw) {
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(raw);
|
|
9
|
+
if (!/(^|\.)linkedin\.com$/i.test(url.hostname))
|
|
10
|
+
return null;
|
|
11
|
+
const match = url.pathname.match(/\/in\/([^/]+)/i);
|
|
12
|
+
const vanity = match?.[1] ? decodeURIComponent(match[1]).trim() : null;
|
|
13
|
+
if (!vanity || vanity.toLowerCase() === "me")
|
|
14
|
+
return null;
|
|
15
|
+
return `https://www.linkedin.com/in/${vanity}/`;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
async function extractLinkedinUrl(context) {
|
|
22
|
+
const cookies = await context.cookies(["https://www.linkedin.com"]);
|
|
23
|
+
if (!cookies.find((c) => c.name === "li_at"))
|
|
24
|
+
return null;
|
|
25
|
+
const page = await context.newPage();
|
|
26
|
+
try {
|
|
27
|
+
// `/in/me/` redirects to the logged-in member's own vanity profile.
|
|
28
|
+
await page.goto("https://www.linkedin.com/in/me/", {
|
|
29
|
+
waitUntil: "domcontentloaded",
|
|
30
|
+
timeout: NAV_TIMEOUT_MS,
|
|
31
|
+
});
|
|
32
|
+
if (BLOCKED_RE.test(page.url()))
|
|
33
|
+
return null;
|
|
34
|
+
const fromRedirect = normalizeLinkedinUrl(page.url());
|
|
35
|
+
if (fromRedirect)
|
|
36
|
+
return fromRedirect;
|
|
37
|
+
// Backup signal if the redirect didn't land on a vanity URL.
|
|
38
|
+
try {
|
|
39
|
+
const canonical = page.locator('link[rel="canonical"]').first();
|
|
40
|
+
await canonical.waitFor({ timeout: 3000 });
|
|
41
|
+
const href = await canonical.getAttribute("href");
|
|
42
|
+
if (href)
|
|
43
|
+
return normalizeLinkedinUrl(href);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// skip
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await page.close().catch(() => undefined);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
export async function scrapeLinkedinUrl() {
|
|
55
|
+
return scrapeFromChromeSession(extractLinkedinUrl);
|
|
56
|
+
}
|
package/dist/twitter-scrape.js
CHANGED
|
@@ -1,267 +1,48 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const TOTAL_TIMEOUT_MS = 40000;
|
|
9
|
-
function wait(ms) {
|
|
10
|
-
return new Promise((r) => setTimeout(r, ms));
|
|
11
|
-
}
|
|
12
|
-
function findChromeExecutable() {
|
|
13
|
-
const os = platform();
|
|
14
|
-
const candidates = os === "darwin"
|
|
15
|
-
? [
|
|
16
|
-
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
17
|
-
"/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta",
|
|
18
|
-
"/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary",
|
|
19
|
-
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
20
|
-
]
|
|
21
|
-
: os === "linux"
|
|
22
|
-
? [
|
|
23
|
-
"/usr/bin/google-chrome",
|
|
24
|
-
"/usr/bin/google-chrome-stable",
|
|
25
|
-
"/usr/bin/chromium",
|
|
26
|
-
"/usr/bin/chromium-browser",
|
|
27
|
-
"/snap/bin/chromium",
|
|
28
|
-
]
|
|
29
|
-
: os === "win32"
|
|
30
|
-
? [
|
|
31
|
-
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
32
|
-
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
33
|
-
]
|
|
34
|
-
: [];
|
|
35
|
-
for (const path of candidates) {
|
|
36
|
-
if (existsSync(path))
|
|
37
|
-
return path;
|
|
38
|
-
}
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
function findChromeProfileRoot() {
|
|
42
|
-
const os = platform();
|
|
43
|
-
const home = homedir();
|
|
44
|
-
const candidates = os === "darwin"
|
|
45
|
-
? [
|
|
46
|
-
join(home, "Library", "Application Support", "Google", "Chrome"),
|
|
47
|
-
join(home, "Library", "Application Support", "Chromium"),
|
|
48
|
-
]
|
|
49
|
-
: os === "linux"
|
|
50
|
-
? [
|
|
51
|
-
join(home, ".config", "google-chrome"),
|
|
52
|
-
join(home, ".config", "chromium"),
|
|
53
|
-
]
|
|
54
|
-
: os === "win32"
|
|
55
|
-
? [join(home, "AppData", "Local", "Google", "Chrome", "User Data")]
|
|
56
|
-
: [];
|
|
57
|
-
for (const path of candidates) {
|
|
58
|
-
if (existsSync(path) && existsSync(join(path, "Local State")))
|
|
59
|
-
return path;
|
|
60
|
-
}
|
|
61
|
-
return null;
|
|
62
|
-
}
|
|
63
|
-
function listProfilesByMtime(profileRoot) {
|
|
64
|
-
try {
|
|
65
|
-
return readdirSync(profileRoot)
|
|
66
|
-
.filter((d) => existsSync(join(profileRoot, d, "Cookies")))
|
|
67
|
-
.map((d) => ({
|
|
68
|
-
name: d,
|
|
69
|
-
mtime: statSync(join(profileRoot, d, "Cookies")).mtimeMs,
|
|
70
|
-
}))
|
|
71
|
-
.sort((a, b) => b.mtime - a.mtime)
|
|
72
|
-
.map((p) => p.name);
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
return [];
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
function prepareProfileCopy(profileRoot, profileName) {
|
|
79
|
-
try {
|
|
80
|
-
const tempRoot = mkdtempSync(join(tmpdir(), "standout-chrome-"));
|
|
81
|
-
mkdirSync(join(tempRoot, "Default"), { recursive: true });
|
|
82
|
-
const localState = join(profileRoot, "Local State");
|
|
83
|
-
if (existsSync(localState)) {
|
|
84
|
-
copyFileSync(localState, join(tempRoot, "Local State"));
|
|
85
|
-
}
|
|
86
|
-
const sourceDir = join(profileRoot, profileName);
|
|
87
|
-
for (const name of ["Cookies", "Cookies-journal", "Preferences"]) {
|
|
88
|
-
const src = join(sourceDir, name);
|
|
89
|
-
if (existsSync(src)) {
|
|
90
|
-
copyFileSync(src, join(tempRoot, "Default", name));
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
return tempRoot;
|
|
94
|
-
}
|
|
95
|
-
catch {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
async function waitForDebugPort(userDataDir, timeoutMs) {
|
|
100
|
-
const deadline = Date.now() + timeoutMs;
|
|
101
|
-
const portFile = join(userDataDir, "DevToolsActivePort");
|
|
102
|
-
while (Date.now() < deadline) {
|
|
103
|
-
if (existsSync(portFile)) {
|
|
104
|
-
try {
|
|
105
|
-
const [port] = readFileSync(portFile, "utf-8").trim().split("\n");
|
|
106
|
-
if (port && /^\d+$/.test(port))
|
|
107
|
-
return parseInt(port, 10);
|
|
108
|
-
}
|
|
109
|
-
catch {
|
|
110
|
-
// keep polling
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
await wait(200);
|
|
114
|
-
}
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
async function spawnChrome(executablePath, userDataDir) {
|
|
118
|
-
const os = platform();
|
|
119
|
-
const args = [
|
|
120
|
-
"--headless=new",
|
|
121
|
-
`--user-data-dir=${userDataDir}`,
|
|
122
|
-
"--remote-debugging-port=0",
|
|
123
|
-
"--no-first-run",
|
|
124
|
-
"--no-default-browser-check",
|
|
125
|
-
"--disable-sync",
|
|
126
|
-
"--disable-features=AutofillServerCommunication",
|
|
127
|
-
];
|
|
128
|
-
const cleanupFns = [];
|
|
129
|
-
if (os === "darwin") {
|
|
130
|
-
// Use `open -n -a` to force a new Chrome process instance on macOS
|
|
131
|
-
// (direct binary launch gets intercepted by the singleton running Chrome).
|
|
132
|
-
const appBundle = executablePath.replace(/\.app\/Contents\/MacOS\/.*$/, ".app");
|
|
133
|
-
const child = spawn("open", ["-n", "-a", appBundle, "--args", ...args], {
|
|
134
|
-
detached: true,
|
|
135
|
-
stdio: "ignore",
|
|
136
|
-
});
|
|
137
|
-
child.unref();
|
|
138
|
-
cleanupFns.push(() => {
|
|
139
|
-
try {
|
|
140
|
-
spawn("pkill", ["-f", `user-data-dir=${userDataDir}`]);
|
|
141
|
-
}
|
|
142
|
-
catch {
|
|
143
|
-
// skip
|
|
144
|
-
}
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
else {
|
|
148
|
-
const child = spawn(executablePath, args, {
|
|
149
|
-
detached: true,
|
|
150
|
-
stdio: "ignore",
|
|
151
|
-
});
|
|
152
|
-
child.unref();
|
|
153
|
-
cleanupFns.push(() => {
|
|
154
|
-
try {
|
|
155
|
-
if (child.pid)
|
|
156
|
-
process.kill(child.pid);
|
|
157
|
-
}
|
|
158
|
-
catch {
|
|
159
|
-
// skip
|
|
160
|
-
}
|
|
161
|
-
});
|
|
162
|
-
}
|
|
163
|
-
const port = await waitForDebugPort(userDataDir, DEBUG_PORT_WAIT_MS);
|
|
164
|
-
return {
|
|
165
|
-
port,
|
|
166
|
-
cleanup: () => cleanupFns.forEach((fn) => fn()),
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
async function extractHandleFromProfile(executablePath, profileRoot, profileName) {
|
|
170
|
-
const tempProfile = prepareProfileCopy(profileRoot, profileName);
|
|
171
|
-
if (!tempProfile)
|
|
1
|
+
import { NAV_TIMEOUT_MS, scrapeFromChromeSession } from "./browser-session.js";
|
|
2
|
+
async function extractTwitterHandle(context) {
|
|
3
|
+
const cookies = await context.cookies([
|
|
4
|
+
"https://x.com",
|
|
5
|
+
"https://twitter.com",
|
|
6
|
+
]);
|
|
7
|
+
if (!cookies.find((c) => c.name === "auth_token"))
|
|
172
8
|
return null;
|
|
173
|
-
const
|
|
174
|
-
let browser = null;
|
|
9
|
+
const page = await context.newPage();
|
|
175
10
|
try {
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
const cookies = await context.cookies([
|
|
182
|
-
"https://x.com",
|
|
183
|
-
"https://twitter.com",
|
|
184
|
-
]);
|
|
185
|
-
const authToken = cookies.find((c) => c.name === "auth_token");
|
|
186
|
-
if (!authToken)
|
|
11
|
+
await page.goto("https://x.com/home", {
|
|
12
|
+
waitUntil: "domcontentloaded",
|
|
13
|
+
timeout: NAV_TIMEOUT_MS,
|
|
14
|
+
});
|
|
15
|
+
if (/\/(login|i\/flow\/login)/.test(page.url()))
|
|
187
16
|
return null;
|
|
188
|
-
const page = await context.newPage();
|
|
189
17
|
try {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
try {
|
|
198
|
-
const link = page.locator('[data-testid="AppTabBar_Profile_Link"]');
|
|
199
|
-
await link.first().waitFor({ timeout: 5000 });
|
|
200
|
-
const href = await link.first().getAttribute("href");
|
|
201
|
-
if (href) {
|
|
202
|
-
const handle = href.replace(/^\//, "").trim();
|
|
203
|
-
if (handle && !handle.includes("/"))
|
|
204
|
-
return handle;
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
catch {
|
|
208
|
-
// fall through
|
|
209
|
-
}
|
|
210
|
-
try {
|
|
211
|
-
const nameElem = page.locator('[data-testid="UserName"] span:has-text("@")');
|
|
212
|
-
const text = await nameElem.first().innerText({ timeout: 3000 });
|
|
213
|
-
const match = text.match(/@([A-Za-z0-9_]+)/);
|
|
214
|
-
if (match)
|
|
215
|
-
return match[1];
|
|
216
|
-
}
|
|
217
|
-
catch {
|
|
218
|
-
// skip
|
|
18
|
+
const link = page.locator('[data-testid="AppTabBar_Profile_Link"]');
|
|
19
|
+
await link.first().waitFor({ timeout: 5000 });
|
|
20
|
+
const href = await link.first().getAttribute("href");
|
|
21
|
+
if (href) {
|
|
22
|
+
const handle = href.replace(/^\//, "").trim();
|
|
23
|
+
if (handle && !handle.includes("/"))
|
|
24
|
+
return handle;
|
|
219
25
|
}
|
|
220
|
-
return null;
|
|
221
|
-
}
|
|
222
|
-
finally {
|
|
223
|
-
await page.close().catch(() => undefined);
|
|
224
26
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return null;
|
|
228
|
-
}
|
|
229
|
-
finally {
|
|
230
|
-
if (browser) {
|
|
231
|
-
await browser.close().catch(() => undefined);
|
|
27
|
+
catch {
|
|
28
|
+
// fall through
|
|
232
29
|
}
|
|
233
|
-
cleanup();
|
|
234
|
-
await wait(500);
|
|
235
30
|
try {
|
|
236
|
-
|
|
31
|
+
const nameElem = page.locator('[data-testid="UserName"] span:has-text("@")');
|
|
32
|
+
const text = await nameElem.first().innerText({ timeout: 3000 });
|
|
33
|
+
const match = text.match(/@([A-Za-z0-9_]+)/);
|
|
34
|
+
if (match)
|
|
35
|
+
return match[1];
|
|
237
36
|
}
|
|
238
37
|
catch {
|
|
239
38
|
// skip
|
|
240
39
|
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
finally {
|
|
43
|
+
await page.close().catch(() => undefined);
|
|
241
44
|
}
|
|
242
|
-
}
|
|
243
|
-
async function withTimeout(promise, ms) {
|
|
244
|
-
return Promise.race([
|
|
245
|
-
promise,
|
|
246
|
-
new Promise((resolve) => setTimeout(() => resolve(null), ms)),
|
|
247
|
-
]);
|
|
248
45
|
}
|
|
249
46
|
export async function scrapeTwitterHandle() {
|
|
250
|
-
|
|
251
|
-
if (!executablePath)
|
|
252
|
-
return null;
|
|
253
|
-
const profileRoot = findChromeProfileRoot();
|
|
254
|
-
if (!profileRoot)
|
|
255
|
-
return null;
|
|
256
|
-
const profiles = listProfilesByMtime(profileRoot);
|
|
257
|
-
if (profiles.length === 0)
|
|
258
|
-
return null;
|
|
259
|
-
return withTimeout((async () => {
|
|
260
|
-
for (const profile of profiles) {
|
|
261
|
-
const handle = await extractHandleFromProfile(executablePath, profileRoot, profile).catch(() => null);
|
|
262
|
-
if (handle)
|
|
263
|
-
return handle;
|
|
264
|
-
}
|
|
265
|
-
return null;
|
|
266
|
-
})(), TOTAL_TIMEOUT_MS);
|
|
47
|
+
return scrapeFromChromeSession(extractTwitterHandle);
|
|
267
48
|
}
|
package/dist/wrapped-client.d.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import type { WrappedAggregateView } from "./wrapped/types.js";
|
|
2
|
+
export interface WrappedGroup {
|
|
3
|
+
slug: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
2
6
|
export interface WrappedReady {
|
|
3
7
|
id: string;
|
|
4
8
|
share_url: string;
|
|
5
9
|
view: WrappedAggregateView;
|
|
10
|
+
group: WrappedGroup | null;
|
|
6
11
|
}
|
|
7
12
|
export declare function createWrapped(args: {
|
|
8
13
|
profile: Record<string, unknown>;
|
|
9
14
|
submissionId?: string | null;
|
|
15
|
+
group?: string | null;
|
|
10
16
|
}): Promise<WrappedReady | null>;
|
package/dist/wrapped-client.js
CHANGED
|
@@ -97,6 +97,7 @@ export async function createWrapped(args) {
|
|
|
97
97
|
const body = JSON.stringify({
|
|
98
98
|
profile,
|
|
99
99
|
submission_id: args.submissionId ?? undefined,
|
|
100
|
+
group: args.group ?? undefined,
|
|
100
101
|
});
|
|
101
102
|
const bodyKb = Math.round(bytesAfter / 1024);
|
|
102
103
|
let created = null;
|
|
@@ -156,5 +157,10 @@ export async function createWrapped(args) {
|
|
|
156
157
|
computed,
|
|
157
158
|
share_url: created.share_url,
|
|
158
159
|
});
|
|
159
|
-
return {
|
|
160
|
+
return {
|
|
161
|
+
id: created.id,
|
|
162
|
+
share_url: created.share_url,
|
|
163
|
+
view,
|
|
164
|
+
group: created.group ?? null,
|
|
165
|
+
};
|
|
160
166
|
}
|