standout 0.5.30 → 0.5.32
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 +223 -10
- package/dist/gather.d.ts +2 -1
- package/dist/gather.js +54 -17
- 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/package.json +2 -2
|
@@ -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
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import Anthropic from "@anthropic-ai/sdk";
|
|
3
3
|
import chalk from "chalk";
|
|
4
|
+
import http from "node:http";
|
|
4
5
|
import { createInterface } from "readline";
|
|
5
6
|
import { markWrappedShared, STANDOUT_API_URL } from "./api.js";
|
|
7
|
+
import { capPayload } from "./payload.js";
|
|
6
8
|
import { gatherFullEnrichment, gatherProfileBasics, gatherUsageStats, } from "./gather.js";
|
|
7
9
|
import { ensureHeapHeadroom } from "./heap.js";
|
|
8
10
|
import { startMcpServer } from "./mcp.js";
|
|
9
11
|
import { configureProxyFromEnv } from "./proxy.js";
|
|
10
12
|
import { SYSTEM_PROMPT } from "./prompt.js";
|
|
11
13
|
import { TOOLS, executeTool } from "./tools.js";
|
|
14
|
+
import { aggregateView } from "./wrapped/aggregate.js";
|
|
12
15
|
import { renderWrappedAll } from "./wrapped/render.js";
|
|
13
16
|
import { createWrapped } from "./wrapped-client.js";
|
|
14
17
|
import { openWrapped } from "./wrapped-share.js";
|
|
@@ -23,6 +26,114 @@ function profileChatEnabled() {
|
|
|
23
26
|
return true;
|
|
24
27
|
return process.argv.slice(2).includes("--chat");
|
|
25
28
|
}
|
|
29
|
+
// Local-only mode: compute and render the wrapped entirely on this machine. No
|
|
30
|
+
// profile is uploaded, no wrapped row is created, no leaderboard entry is made,
|
|
31
|
+
// and gathering skips every network call. Enable with --local / --no-upload or
|
|
32
|
+
// STANDOUT_LOCAL=1.
|
|
33
|
+
function localOnlyEnabled() {
|
|
34
|
+
const env = process.env.STANDOUT_LOCAL;
|
|
35
|
+
if (env === "1" || env === "true")
|
|
36
|
+
return true;
|
|
37
|
+
const args = process.argv.slice(2);
|
|
38
|
+
return args.includes("--local") || args.includes("--no-upload");
|
|
39
|
+
}
|
|
40
|
+
function printBanner() {
|
|
41
|
+
console.log("\n ┌────────────────────────────────┐");
|
|
42
|
+
console.log(" │ are you really tokenmaxxing? │");
|
|
43
|
+
console.log(" │ Powered by Standout (YC P26) │");
|
|
44
|
+
console.log(" └────────────────────────────────┘\n");
|
|
45
|
+
}
|
|
46
|
+
// First step of the default flow: an arrow-key menu to pick full (cloud) vs
|
|
47
|
+
// local-only. Non-interactive shells (CI, agents) can't answer, so they keep the
|
|
48
|
+
// full flow — pass --local / STANDOUT_LOCAL=1 to force local without a prompt.
|
|
49
|
+
const MODE_OPTIONS = [
|
|
50
|
+
{
|
|
51
|
+
key: "full",
|
|
52
|
+
title: "Full",
|
|
53
|
+
tag: " (recommended)",
|
|
54
|
+
lines: [
|
|
55
|
+
"Your full AI wrapped: LLM-written cards, your score, and a leaderboard rank.",
|
|
56
|
+
"Reads your local stats plus your public GitHub / LinkedIn / X.",
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
key: "local",
|
|
61
|
+
title: "Local only",
|
|
62
|
+
tag: " — private & offline",
|
|
63
|
+
lines: [
|
|
64
|
+
"Stays entirely on your computer. Nothing uploaded, nothing saved.",
|
|
65
|
+
"Just your local AI-tool stats — no LLM cards, score, or ranking.",
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
function renderModeMenu(selected) {
|
|
70
|
+
const out = [
|
|
71
|
+
"",
|
|
72
|
+
chalk.white(" How do you want to run Standout?"),
|
|
73
|
+
"",
|
|
74
|
+
];
|
|
75
|
+
MODE_OPTIONS.forEach((opt, i) => {
|
|
76
|
+
const active = i === selected;
|
|
77
|
+
const pointer = active ? chalk.hex("#ad5cff")("❯ ") : " ";
|
|
78
|
+
const title = active
|
|
79
|
+
? chalk.bold.white(opt.title) + chalk.dim(opt.tag)
|
|
80
|
+
: chalk.dim(opt.title + opt.tag);
|
|
81
|
+
out.push(`${pointer}${title}`);
|
|
82
|
+
for (const line of opt.lines)
|
|
83
|
+
out.push(chalk.dim(` ${line}`));
|
|
84
|
+
out.push("");
|
|
85
|
+
});
|
|
86
|
+
out.push(chalk.dim(" ↑/↓ to move · enter to select"));
|
|
87
|
+
return out.join("\n");
|
|
88
|
+
}
|
|
89
|
+
async function chooseMode() {
|
|
90
|
+
if (!process.stdin.isTTY)
|
|
91
|
+
return "full";
|
|
92
|
+
let selected = 0;
|
|
93
|
+
const draw = () => {
|
|
94
|
+
const text = renderModeMenu(selected);
|
|
95
|
+
process.stderr.write(text + "\n");
|
|
96
|
+
return text.split("\n").length;
|
|
97
|
+
};
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
const stdin = process.stdin;
|
|
100
|
+
stdin.setRawMode(true);
|
|
101
|
+
stdin.resume();
|
|
102
|
+
stdin.setEncoding("utf8");
|
|
103
|
+
let lines = draw();
|
|
104
|
+
const redraw = () => {
|
|
105
|
+
// Jump back to the menu's first line and clear it before redrawing.
|
|
106
|
+
process.stderr.write(`\x1b[${lines}A\x1b[0J`);
|
|
107
|
+
lines = draw();
|
|
108
|
+
};
|
|
109
|
+
const cleanup = () => {
|
|
110
|
+
stdin.setRawMode(false);
|
|
111
|
+
stdin.pause();
|
|
112
|
+
stdin.removeListener("data", onData);
|
|
113
|
+
};
|
|
114
|
+
const onData = (data) => {
|
|
115
|
+
if (data === "\x03") {
|
|
116
|
+
cleanup();
|
|
117
|
+
process.stderr.write("\n Cancelled.\n");
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
else if (data === "\r" || data === "\n") {
|
|
121
|
+
cleanup();
|
|
122
|
+
process.stderr.write("\n");
|
|
123
|
+
resolve(MODE_OPTIONS[selected].key);
|
|
124
|
+
}
|
|
125
|
+
else if (data === "\x1b[A" || data === "k") {
|
|
126
|
+
selected = (selected + MODE_OPTIONS.length - 1) % MODE_OPTIONS.length;
|
|
127
|
+
redraw();
|
|
128
|
+
}
|
|
129
|
+
else if (data === "\x1b[B" || data === "j") {
|
|
130
|
+
selected = (selected + 1) % MODE_OPTIONS.length;
|
|
131
|
+
redraw();
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
stdin.on("data", onData);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
26
137
|
async function confirmPrivacy() {
|
|
27
138
|
process.stderr.write("\nDiscover your Claude Code, Codex, and Cursor stats, then see how you rank.\n" +
|
|
28
139
|
"You'll see your cards first, then review before submitting.\n\n" +
|
|
@@ -125,6 +236,94 @@ async function maybeShareWrapped(wrapped) {
|
|
|
125
236
|
process.stderr.write(` (${err instanceof Error ? err.message : err})\n\n`);
|
|
126
237
|
}
|
|
127
238
|
}
|
|
239
|
+
// Fully offline path: gather only local signals and render the wrapped deck in
|
|
240
|
+
// the terminal without ever contacting Standout. Reads only local fields, so the
|
|
241
|
+
// deck looks the same minus the server-computed bits — the LLM-written
|
|
242
|
+
// archetype/zinger (sensible defaults are used) and the leaderboard
|
|
243
|
+
// rank/percentiles (omitted, since those need the server cohort).
|
|
244
|
+
async function renderLocalTerminal(prefetched) {
|
|
245
|
+
const view = aggregateView({
|
|
246
|
+
profile: prefetched,
|
|
247
|
+
computed: {},
|
|
248
|
+
share_url: "",
|
|
249
|
+
});
|
|
250
|
+
await renderWrappedAll(view);
|
|
251
|
+
}
|
|
252
|
+
// Local path: gather on-device, then open the real wrapped UI on localhost. The
|
|
253
|
+
// gathered profile is held only in this process and served from an ephemeral
|
|
254
|
+
// in-memory HTTP server — nothing is written to disk, no DB row, no leaderboard
|
|
255
|
+
// entry. The page renders the card image via a stateless endpoint (also not
|
|
256
|
+
// stored) and shows the share options. `--terminal` forces the fully-offline
|
|
257
|
+
// ASCII deck instead.
|
|
258
|
+
async function runLocal() {
|
|
259
|
+
const terminalOnly = process.argv.slice(2).includes("--terminal");
|
|
260
|
+
const stop = startLoadingLine("Putting together your AI highlights");
|
|
261
|
+
let prefetched;
|
|
262
|
+
try {
|
|
263
|
+
prefetched = await gatherFullEnrichment({
|
|
264
|
+
localOnly: true,
|
|
265
|
+
verbose: false,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
stop();
|
|
270
|
+
}
|
|
271
|
+
// Always page the deck in the terminal first.
|
|
272
|
+
await renderLocalTerminal(prefetched);
|
|
273
|
+
if (terminalOnly) {
|
|
274
|
+
process.stderr.write("\n Local run complete. To publish your wrapped and see how you rank: npx standout\n\n");
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
// Then open the full wrapped (card image + share options) on localhost.
|
|
278
|
+
// Trim the bulkiest raw fields so the in-memory body stays well under the
|
|
279
|
+
// render endpoint's request cap, same as the upload path.
|
|
280
|
+
const { payload } = capPayload(prefetched);
|
|
281
|
+
const body = JSON.stringify(payload);
|
|
282
|
+
const server = http.createServer((req, res) => {
|
|
283
|
+
// The wrapped page (https) reads this over http://localhost, which browsers
|
|
284
|
+
// treat as a trustworthy origin. Allow any origin for the simple GET.
|
|
285
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
286
|
+
if (req.url && req.url.startsWith("/profile.json")) {
|
|
287
|
+
res.setHeader("Content-Type", "application/json");
|
|
288
|
+
res.end(body);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
res.statusCode = 404;
|
|
292
|
+
res.end("not found");
|
|
293
|
+
});
|
|
294
|
+
await new Promise((resolve, reject) => {
|
|
295
|
+
server.on("error", reject);
|
|
296
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
297
|
+
});
|
|
298
|
+
const address = server.address();
|
|
299
|
+
const port = address && typeof address === "object" ? address.port : null;
|
|
300
|
+
if (!port) {
|
|
301
|
+
server.close();
|
|
302
|
+
process.stderr.write("\n Couldn't start the local server, so the browser view is unavailable.\n" +
|
|
303
|
+
" Your wrapped is shown above. Re-run to try the browser view: npx standout --local\n\n");
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const url = `${STANDOUT_API_URL}/wrapped/live?port=${port}`;
|
|
307
|
+
process.stderr.write(`\n Your wrapped is ready — nothing was uploaded or saved.\n` +
|
|
308
|
+
` ${url}\n\n` +
|
|
309
|
+
` Opening your browser. Keep this terminal open while you view or share.\n` +
|
|
310
|
+
` Press Ctrl+C when you're done.\n\n`);
|
|
311
|
+
try {
|
|
312
|
+
await openWrapped(url);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
process.stderr.write(` Couldn't open the browser automatically. Open this link manually:\n ${url}\n\n`);
|
|
316
|
+
}
|
|
317
|
+
// Hold the process open (the in-memory server keeps the data available) until
|
|
318
|
+
// the user exits; then drop everything.
|
|
319
|
+
await new Promise((resolve) => {
|
|
320
|
+
process.on("SIGINT", () => {
|
|
321
|
+
server.close();
|
|
322
|
+
process.stderr.write("\n Closed. Nothing was saved.\n");
|
|
323
|
+
resolve();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
}
|
|
128
327
|
async function runAgent(jobId) {
|
|
129
328
|
// The wrapped render + share now run non-interactively (no TTY required), so
|
|
130
329
|
// the command completes when invoked from a Claude Code Bash call. Only the
|
|
@@ -132,10 +331,6 @@ async function runAgent(jobId) {
|
|
|
132
331
|
if (profileChatEnabled()) {
|
|
133
332
|
ensureInteractive("The interactive profile chat");
|
|
134
333
|
}
|
|
135
|
-
console.log("\n ┌────────────────────────────────┐");
|
|
136
|
-
console.log(" │ are you really tokenmaxxing? │");
|
|
137
|
-
console.log(" │ Powered by Standout (YC P26) │");
|
|
138
|
-
console.log(" └────────────────────────────────┘\n");
|
|
139
334
|
// Only frame the token as a job in the application flow. Otherwise it may be a
|
|
140
335
|
// leaderboard group (`npx standout stanford`), confirmed after the wrapped runs.
|
|
141
336
|
if (jobId && profileChatEnabled()) {
|
|
@@ -340,19 +535,37 @@ async function main() {
|
|
|
340
535
|
const proxy = configureProxyFromEnv();
|
|
341
536
|
if (proxy)
|
|
342
537
|
process.stderr.write(` (using proxy ${proxy})\n`);
|
|
538
|
+
const localOnly = localOnlyEnabled();
|
|
343
539
|
if (args.includes("--gather")) {
|
|
344
|
-
|
|
345
|
-
|
|
540
|
+
// Local-only gather skips the upload-consent gate (nothing leaves the box).
|
|
541
|
+
if (!localOnly)
|
|
542
|
+
await confirmPrivacy();
|
|
543
|
+
const data = await gatherFullEnrichment({ localOnly });
|
|
346
544
|
process.stdout.write(JSON.stringify(data, null, 2));
|
|
545
|
+
return;
|
|
347
546
|
}
|
|
348
|
-
|
|
547
|
+
if (args.includes("--mcp")) {
|
|
349
548
|
await startMcpServer();
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const jobId = args.find((a) => !a.startsWith("-"));
|
|
552
|
+
printBanner();
|
|
553
|
+
// Pick the mode. An explicit --local/--no-upload/STANDOUT_LOCAL forces local;
|
|
554
|
+
// a group/job arg (`npx standout stanford`) is an explicit full-flow intent; a
|
|
555
|
+
// bare `npx standout` asks the user as the first step.
|
|
556
|
+
let mode;
|
|
557
|
+
if (localOnly)
|
|
558
|
+
mode = "local";
|
|
559
|
+
else if (jobId)
|
|
560
|
+
mode = "full";
|
|
561
|
+
else
|
|
562
|
+
mode = await chooseMode();
|
|
563
|
+
if (mode === "local") {
|
|
564
|
+
await runLocal();
|
|
350
565
|
}
|
|
351
566
|
else {
|
|
352
|
-
|
|
353
|
-
if (jobId) {
|
|
567
|
+
if (jobId)
|
|
354
568
|
process.env.STANDOUT_JOB_ID = jobId;
|
|
355
|
-
}
|
|
356
569
|
await runAgent(jobId);
|
|
357
570
|
}
|
|
358
571
|
}
|
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
|
};
|
|
@@ -72,6 +72,7 @@ export interface FullEnrichmentOptions {
|
|
|
72
72
|
aiUsage?: AiUsage;
|
|
73
73
|
profileBasics?: ProfileBasics;
|
|
74
74
|
verbose?: boolean;
|
|
75
|
+
localOnly?: boolean;
|
|
75
76
|
}
|
|
76
77
|
export declare function emptyGatheredProfile(readiness?: GatherReadiness): GatheredProfile;
|
|
77
78
|
export declare function gatherUsageStats(): Promise<Pick<GatheredProfile, "ai_usage" | "readiness">>;
|
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",
|
|
@@ -443,6 +445,7 @@ function trimNpmPackage(p) {
|
|
|
443
445
|
};
|
|
444
446
|
}
|
|
445
447
|
export async function gather(options = {}) {
|
|
448
|
+
const localOnly = options.localOnly === true;
|
|
446
449
|
const profileBasics = options.profileBasics?.readiness.profile_basics_status === "ready"
|
|
447
450
|
? options.profileBasics
|
|
448
451
|
: undefined;
|
|
@@ -466,24 +469,32 @@ export async function gather(options = {}) {
|
|
|
466
469
|
remoteCandidates.push(match[1]);
|
|
467
470
|
}
|
|
468
471
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
472
|
+
if (localOnly) {
|
|
473
|
+
// No network to verify User vs Org — take the first remote candidate as a
|
|
474
|
+
// best-effort handle. Only used to label identity; the deck falls back to
|
|
475
|
+
// the git name regardless.
|
|
476
|
+
githubUsername = remoteCandidates[0] ?? null;
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
// Resolve to a USER (not an Organization) profile
|
|
480
|
+
for (const candidate of remoteCandidates) {
|
|
481
|
+
const profile = (await fetchGitHub(`https://api.github.com/users/${sanitize(candidate)}`));
|
|
482
|
+
if (profile?.type === "User" && profile.login) {
|
|
483
|
+
githubUsername = profile.login;
|
|
484
|
+
break;
|
|
485
|
+
}
|
|
475
486
|
}
|
|
476
487
|
}
|
|
477
488
|
}
|
|
478
489
|
// Fallback: search GitHub by email (public profile email match)
|
|
479
|
-
if (!githubUsername && email) {
|
|
490
|
+
if (!githubUsername && email && !localOnly) {
|
|
480
491
|
const searchResult = (await fetchGitHub(`https://api.github.com/search/users?q=${encodeURIComponent(email)}+in:email`));
|
|
481
492
|
if (searchResult?.items?.[0]) {
|
|
482
493
|
githubUsername = searchResult.items[0].login;
|
|
483
494
|
}
|
|
484
495
|
}
|
|
485
496
|
// Fallback: search commits by author-email (catches users with private email)
|
|
486
|
-
if (!githubUsername && email) {
|
|
497
|
+
if (!githubUsername && email && !localOnly) {
|
|
487
498
|
const commitSearch = (await fetchGitHub(`https://api.github.com/search/commits?q=author-email:${encodeURIComponent(email)}`));
|
|
488
499
|
const login = commitSearch?.items?.[0]?.author?.login;
|
|
489
500
|
if (login)
|
|
@@ -495,7 +506,7 @@ export async function gather(options = {}) {
|
|
|
495
506
|
gatherLog(options, "Gathering GitHub data...\n");
|
|
496
507
|
const canUseCachedGithubBasics = Boolean(profileBasics?.identity.github_username) &&
|
|
497
508
|
profileBasics?.identity.github_username === githubUsername;
|
|
498
|
-
const [ghProfile, ghRepos, ghStarred, ghFollowing, ghEvents, ghPrs, ghContrib, ghNpm,] = safeUsername
|
|
509
|
+
const [ghProfile, ghRepos, ghStarred, ghFollowing, ghEvents, ghPrs, ghContrib, ghNpm,] = safeUsername && !localOnly
|
|
499
510
|
? await Promise.all([
|
|
500
511
|
canUseCachedGithubBasics
|
|
501
512
|
? Promise.resolve(profileBasics?.github.profile ?? null)
|
|
@@ -518,7 +529,7 @@ export async function gather(options = {}) {
|
|
|
518
529
|
const repoContents = {};
|
|
519
530
|
const readmes = {};
|
|
520
531
|
const commitMessages = {};
|
|
521
|
-
if (safeUsername) {
|
|
532
|
+
if (safeUsername && !localOnly) {
|
|
522
533
|
const contentPromises = topRepos.map(async (repo) => {
|
|
523
534
|
const repoName = repo.name;
|
|
524
535
|
const contents = (await fetchGitHub(`https://api.github.com/repos/${safeUsername}/${repoName}/contents`));
|
|
@@ -553,7 +564,7 @@ export async function gather(options = {}) {
|
|
|
553
564
|
}
|
|
554
565
|
// Phase 2c: AI tool usage + dev environment (run in background during LinkedIn)
|
|
555
566
|
gatherLog(options, "Scanning AI tool usage and dev environment...\n");
|
|
556
|
-
const githubEnrichPromise = safeUsername
|
|
567
|
+
const githubEnrichPromise = safeUsername && !localOnly
|
|
557
568
|
? enrichApi("github", safeUsername).catch(() => null)
|
|
558
569
|
: Promise.resolve(null);
|
|
559
570
|
const aiUsagePromise = options.aiUsage
|
|
@@ -581,7 +592,7 @@ export async function gather(options = {}) {
|
|
|
581
592
|
let linkedinResolutionSource = null;
|
|
582
593
|
let linkedinProfile = null;
|
|
583
594
|
const companyEnrichments = {};
|
|
584
|
-
if (safeEmail) {
|
|
595
|
+
if (safeEmail && !localOnly) {
|
|
585
596
|
const emailResult = await enrichApi("email_to_linkedin", email);
|
|
586
597
|
if (emailResult && typeof emailResult === "object") {
|
|
587
598
|
linkedinUrl =
|
|
@@ -592,7 +603,7 @@ export async function gather(options = {}) {
|
|
|
592
603
|
}
|
|
593
604
|
}
|
|
594
605
|
// Fallback: name + signals via AI web search when email lookup comes up empty
|
|
595
|
-
if (!linkedinUrl && ghProfile && name) {
|
|
606
|
+
if (!linkedinUrl && ghProfile && name && !localOnly) {
|
|
596
607
|
gatherLog(options, "Resolving LinkedIn via name + signals...\n");
|
|
597
608
|
const profile = ghProfile;
|
|
598
609
|
const signals = [];
|
|
@@ -627,6 +638,24 @@ export async function gather(options = {}) {
|
|
|
627
638
|
linkedinResolutionSource = "ai_search";
|
|
628
639
|
}
|
|
629
640
|
}
|
|
641
|
+
// Fallback: read a logged-in LinkedIn session from the local Chrome profile
|
|
642
|
+
// (headless, silent). Same technique as the X handle scrape — the only anchor
|
|
643
|
+
// for users with no git email/name and no GitHub remote. Reads the local
|
|
644
|
+
// browser but resolves a URL the enrich proxy would then look up, so it's
|
|
645
|
+
// skipped in local-only mode too.
|
|
646
|
+
if (!linkedinUrl && !localOnly) {
|
|
647
|
+
gatherLog(options, "Checking for a LinkedIn session in Chrome...\n");
|
|
648
|
+
try {
|
|
649
|
+
const scraped = await scrapeLinkedinUrl();
|
|
650
|
+
if (scraped) {
|
|
651
|
+
linkedinUrl = scraped;
|
|
652
|
+
linkedinResolutionSource = "chrome_session";
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
catch {
|
|
656
|
+
// silent
|
|
657
|
+
}
|
|
658
|
+
}
|
|
630
659
|
if (linkedinUrl) {
|
|
631
660
|
linkedinProfile = await enrichApi("linkedin_profile", linkedinUrl);
|
|
632
661
|
// Company enrichment
|
|
@@ -660,7 +689,7 @@ export async function gather(options = {}) {
|
|
|
660
689
|
}
|
|
661
690
|
}
|
|
662
691
|
// Fallback: scrape the user's logged-in Chrome session for an X handle.
|
|
663
|
-
if (!twitterHandle) {
|
|
692
|
+
if (!twitterHandle && !localOnly) {
|
|
664
693
|
gatherLog(options, "Checking for an X/Twitter session in Chrome...\n");
|
|
665
694
|
try {
|
|
666
695
|
const scraped = await scrapeTwitterHandle();
|
|
@@ -673,7 +702,7 @@ export async function gather(options = {}) {
|
|
|
673
702
|
}
|
|
674
703
|
const safeTwitter = twitterHandle ? sanitize(twitterHandle) : null;
|
|
675
704
|
let twitterProfile = null;
|
|
676
|
-
if (safeTwitter) {
|
|
705
|
+
if (safeTwitter && !localOnly) {
|
|
677
706
|
twitterProfile = await enrichApi("twitter", safeTwitter);
|
|
678
707
|
}
|
|
679
708
|
const twitterUrl = safeTwitter ? `https://x.com/${safeTwitter}` : null;
|
|
@@ -821,14 +850,22 @@ export async function gather(options = {}) {
|
|
|
821
850
|
githubEnrichPromise,
|
|
822
851
|
]);
|
|
823
852
|
gatherLog(options, "Done gathering.\n");
|
|
853
|
+
const resolvedName = resolveDisplayName({
|
|
854
|
+
gitName: name,
|
|
855
|
+
githubName: ghProfile?.name,
|
|
856
|
+
linkedinProfile: linkedinProfile,
|
|
857
|
+
githubUsername,
|
|
858
|
+
twitterHandle,
|
|
859
|
+
});
|
|
824
860
|
return {
|
|
825
861
|
readiness: {
|
|
826
862
|
usage_stats_status: hasAnyUsage(aiUsage) ? "ready" : "unavailable",
|
|
827
863
|
profile_basics_status: "ready",
|
|
828
|
-
|
|
864
|
+
// Local-only mode performs no network enrichment.
|
|
865
|
+
enrichment_status: localOnly ? "unavailable" : "ready",
|
|
829
866
|
},
|
|
830
867
|
identity: {
|
|
831
|
-
name:
|
|
868
|
+
name: resolvedName,
|
|
832
869
|
email: email || null,
|
|
833
870
|
github_username: githubUsername,
|
|
834
871
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "standout",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.32",
|
|
4
4
|
"description": "Build your developer profile with AI. One command, zero friction.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/cli.js",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "tsc",
|
|
21
|
-
"dev": "
|
|
21
|
+
"dev": "tsc && node dist/cli.js",
|
|
22
22
|
"test": "tsx --test tests/*.test.ts",
|
|
23
23
|
"preview:tiers": "tsx src/wrapped/preview.ts",
|
|
24
24
|
"typecheck": "tsc --noEmit",
|