website-api 1.1.2 → 1.1.4
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/README.md +141 -1
- package/dist/bin/cli.js +204 -1
- package/dist/src/capabilities/browser.d.ts +8 -2
- package/dist/src/capabilities/browser.js +106 -1
- package/dist/src/capabilities/cookies.d.ts +7 -1
- package/dist/src/capabilities/cookies.js +68 -1
- package/dist/src/capabilities/download.js +32 -1
- package/dist/src/capabilities/fingerprint.js +62 -1
- package/dist/src/capabilities/http.js +101 -1
- package/dist/src/capabilities/login/login-helper.js +185 -1
- package/dist/src/capabilities/login/login-strategy.js +36 -1
- package/dist/src/challenges/perimeterx.d.ts +62 -0
- package/dist/src/challenges/perimeterx.js +112 -0
- package/dist/src/cli/ext.js +338 -1
- package/dist/src/core/context.d.ts +2 -2
- package/dist/src/core/context.js +137 -1
- package/dist/src/core/define-site.js +74 -1
- package/dist/src/core/loader.js +142 -1
- package/dist/src/core/registry.js +332 -1
- package/dist/src/core/runtime.d.ts +12 -4
- package/dist/src/core/runtime.js +98 -1
- package/dist/src/env.js +34 -1
- package/dist/src/sites/bloomberg.com/index.d.ts +11 -0
- package/dist/src/sites/bloomberg.com/index.js +49 -0
- package/dist/src/sites/bloomberg.com/openapi.yaml +38 -0
- package/dist/src/sites/chase.com/download-helper.js +266 -1
- package/dist/src/sites/chase.com/index.js +87 -1
- package/dist/src/sites/chase.com/openapi.yaml +76 -0
- package/dist/src/sites/chatgpt.com/index.js +24 -1
- package/dist/src/sites/chatgpt.com/openapi.yaml +29 -0
- package/dist/src/sites/claude.ai/claude-helpers.d.ts +20 -0
- package/dist/src/sites/claude.ai/claude-helpers.js +26 -0
- package/dist/src/sites/claude.ai/index.d.ts +2 -0
- package/dist/src/sites/claude.ai/index.js +42 -0
- package/dist/src/sites/claude.ai/openapi.yaml +54 -0
- package/dist/src/sites/cursor.com/index.js +12 -1
- package/dist/src/sites/cursor.com/openapi.yaml +39 -0
- package/dist/src/sites/e-zpassny.com/index.d.ts +2 -0
- package/dist/src/sites/e-zpassny.com/index.js +344 -0
- package/dist/src/sites/e-zpassny.com/openapi.yaml +68 -0
- package/dist/src/sites/gemini.google.com/index.d.ts +11 -0
- package/dist/src/sites/gemini.google.com/index.js +80 -1
- package/dist/src/sites/gemini.google.com/openapi.yaml +39 -0
- package/dist/src/sites/google.com/google-helpers.js +255 -1
- package/dist/src/sites/google.com/index.js +253 -1
- package/dist/src/sites/google.com/openapi.yaml +59 -0
- package/dist/src/sites/ollama.com/index.js +43 -1
- package/dist/src/sites/ollama.com/openapi.yaml +39 -0
- package/dist/src/sites/perplexity.ai/index.js +253 -1
- package/dist/src/sites/perplexity.ai/openapi.yaml +51 -0
- package/dist/src/sites/pseg.com/index.js +243 -1
- package/dist/src/sites/pseg.com/openapi.yaml +42 -0
- package/dist/src/sites/pseg.com/pseg-helpers.js +53 -1
- package/dist/src/sites/voice.google.com/index.d.ts +2 -0
- package/dist/src/sites/voice.google.com/index.js +122 -0
- package/dist/src/sites/voice.google.com/openapi.yaml +67 -0
- package/dist/src/sites/voice.google.com/voice-helpers.d.ts +105 -0
- package/dist/src/sites/voice.google.com/voice-helpers.js +181 -0
- package/dist/src/sites/zillow.com/index.d.ts +2 -0
- package/dist/src/sites/zillow.com/index.js +303 -0
- package/dist/src/sites/zillow.com/openapi.yaml +55 -0
- package/dist/src/types.d.ts +16 -0
- package/dist/src/types.js +1 -1
- package/dist/src/util/args-parser.js +145 -1
- package/dist/src/util/google-json.js +74 -1
- package/dist/src/website-api.d.ts +7 -7
- package/dist/src/website-api.js +13 -1
- package/package.json +37 -10
|
@@ -1 +1,53 @@
|
|
|
1
|
-
|
|
1
|
+
// Pure PSEG helpers — no Playwright, directly unit-testable.
|
|
2
|
+
const INTERVAL_MAP = {
|
|
3
|
+
"15": "15-Minute",
|
|
4
|
+
"15-minute": "15-Minute",
|
|
5
|
+
"30": "30-Minute",
|
|
6
|
+
"30-minute": "30-Minute",
|
|
7
|
+
hourly: "Hourly",
|
|
8
|
+
daily: "Daily",
|
|
9
|
+
weekly: "Weekly",
|
|
10
|
+
monthly: "Monthly",
|
|
11
|
+
billing: "Billing",
|
|
12
|
+
};
|
|
13
|
+
const INTERVAL_TO_VALUE = {
|
|
14
|
+
"15-Minute": "3",
|
|
15
|
+
"30-Minute": "4",
|
|
16
|
+
Hourly: "5",
|
|
17
|
+
Daily: "6",
|
|
18
|
+
Weekly: "8",
|
|
19
|
+
Monthly: "9",
|
|
20
|
+
Billing: "7",
|
|
21
|
+
};
|
|
22
|
+
export function normalizeInterval(value) {
|
|
23
|
+
const raw = String(value || "")
|
|
24
|
+
.trim()
|
|
25
|
+
.toLowerCase();
|
|
26
|
+
const interval = INTERVAL_MAP[raw];
|
|
27
|
+
if (!interval) {
|
|
28
|
+
throw new Error(`Invalid interval: ${value}. Use 15, 30, hourly, daily, weekly, monthly, or billing.`);
|
|
29
|
+
}
|
|
30
|
+
return interval;
|
|
31
|
+
}
|
|
32
|
+
export function intervalToValue(interval) {
|
|
33
|
+
return INTERVAL_TO_VALUE[interval];
|
|
34
|
+
}
|
|
35
|
+
export function inferServiceTypeFromPropertyTitle(title) {
|
|
36
|
+
if (/\bgas\b/i.test(title))
|
|
37
|
+
return "Gas";
|
|
38
|
+
if (/\belectric\b/i.test(title))
|
|
39
|
+
return "Electric";
|
|
40
|
+
throw new Error(`Could not infer service type from property title: ${title}`);
|
|
41
|
+
}
|
|
42
|
+
export function extractAddressNumber(address) {
|
|
43
|
+
const match = String(address || "").match(/^(\d+)/);
|
|
44
|
+
return match ? match[1] : "";
|
|
45
|
+
}
|
|
46
|
+
export function formatPropertyLabel(property) {
|
|
47
|
+
const title = String(property.title || "").trim();
|
|
48
|
+
if (title)
|
|
49
|
+
return title;
|
|
50
|
+
const propertyType = property.propertyType || inferServiceTypeFromPropertyTitle(property.title);
|
|
51
|
+
const addressNumber = extractAddressNumber(property.address) || property.propertyId || "Unknown";
|
|
52
|
+
return `${addressNumber} ${propertyType}`;
|
|
53
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { defineSite } from "../../core/define-site.js";
|
|
2
|
+
import { buildListBody, buildSapisidAuth, decodeSendResult, decodeThreadList, normalizeNumber, SEND_SMS_PATH, selectGoogleCookies, threadUrl, toThreadId, voiceHeaders, voiceUrl, } from "./voice-helpers.js";
|
|
3
|
+
/**
|
|
4
|
+
* POSTs a JSON+protobuf body to a voiceclient method and returns the parsed
|
|
5
|
+
* array. Cookies are filtered to google.com + deduped, and the SAPISIDHASH is
|
|
6
|
+
* recomputed per call. We pass our own `Cookie` header so `ctx.http` doesn't
|
|
7
|
+
* inject the raw (cross-domain, duplicated) cookie set.
|
|
8
|
+
*/
|
|
9
|
+
async function callVoice(ctx, method, body) {
|
|
10
|
+
const { header, byName } = selectGoogleCookies(ctx.cookies());
|
|
11
|
+
const timestamp = Math.floor(Date.now() / 1000);
|
|
12
|
+
const authorization = buildSapisidAuth(byName, timestamp);
|
|
13
|
+
const raw = await ctx.http.text(voiceUrl(method), {
|
|
14
|
+
method: "POST",
|
|
15
|
+
headers: voiceHeaders(authorization, header),
|
|
16
|
+
body: JSON.stringify(body),
|
|
17
|
+
});
|
|
18
|
+
if (ctx.debug)
|
|
19
|
+
console.error(`[voice] ${method} ->`, raw.slice(0, 200));
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(raw);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new Error(`Google Voice ${method} returned a non-JSON response (are you logged in?): ${raw.slice(0, 160)}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export default defineSite({
|
|
28
|
+
id: "google-voice",
|
|
29
|
+
name: "Google Voice",
|
|
30
|
+
domain: "voice.google.com",
|
|
31
|
+
// Session cookies (SID, SAPISID, *PAPISID) are scoped to google.com, not the subdomain.
|
|
32
|
+
cookieDomain: "google.com",
|
|
33
|
+
description: "List threads and read conversations over HTTP (no browser); send SMS via the attached browser.",
|
|
34
|
+
cookies: "required",
|
|
35
|
+
endpoints: [{ url: "https://voice.google.com/u/0/messages" }],
|
|
36
|
+
positionals: [
|
|
37
|
+
{ name: "action", description: "list | read | send (default: list)", required: false },
|
|
38
|
+
{ name: "target", description: "phone number or thread id (for read/send)", required: false },
|
|
39
|
+
{ name: "message", description: "message text to send (for send)", required: false, variadic: true },
|
|
40
|
+
],
|
|
41
|
+
parameters: [
|
|
42
|
+
{ name: "limit", type: "number", description: "max threads to return", default: 25, short: "n" },
|
|
43
|
+
{
|
|
44
|
+
name: "messages",
|
|
45
|
+
type: "number",
|
|
46
|
+
description: "max messages per thread to fetch",
|
|
47
|
+
default: 15,
|
|
48
|
+
short: "m",
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "authuser",
|
|
52
|
+
type: "number",
|
|
53
|
+
description: "Google account index in the URL (/u/N/) for send",
|
|
54
|
+
default: 0,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
run: async (ctx) => {
|
|
58
|
+
const action = String(ctx.options.action || "list").toLowerCase();
|
|
59
|
+
const threadLimit = Number(ctx.options.limit) || 25;
|
|
60
|
+
const perThread = Number(ctx.options.messages) || 15;
|
|
61
|
+
if (action === "list") {
|
|
62
|
+
const body = await callVoice(ctx, "api2thread/list", buildListBody(threadLimit, perThread));
|
|
63
|
+
return decodeThreadList(body)
|
|
64
|
+
.slice(0, threadLimit)
|
|
65
|
+
.map(({ messages, ...summary }) => summary);
|
|
66
|
+
}
|
|
67
|
+
if (action === "read") {
|
|
68
|
+
const target = ctx.options.target;
|
|
69
|
+
if (!target)
|
|
70
|
+
throw new Error("read requires a phone number or thread id, e.g. `google-voice read +17185139789`");
|
|
71
|
+
const wantedId = toThreadId(String(target));
|
|
72
|
+
const body = await callVoice(ctx, "api2thread/list", buildListBody(threadLimit, perThread));
|
|
73
|
+
const thread = decodeThreadList(body).find((t) => t.threadId === wantedId);
|
|
74
|
+
if (!thread) {
|
|
75
|
+
throw new Error(`No thread found for ${target} (looked for ${wantedId}). It may be older than the latest ${threadLimit} threads.`);
|
|
76
|
+
}
|
|
77
|
+
// Oldest → newest reads more naturally than the API's newest-first order.
|
|
78
|
+
return { ...thread, messages: [...thread.messages].reverse() };
|
|
79
|
+
}
|
|
80
|
+
if (action === "send") {
|
|
81
|
+
const target = ctx.options.target;
|
|
82
|
+
const message = Array.isArray(ctx.options.message)
|
|
83
|
+
? ctx.options.message.join(" ")
|
|
84
|
+
: ctx.options.message;
|
|
85
|
+
if (!target || !message) {
|
|
86
|
+
throw new Error('send requires a number and text, e.g. `google-voice send +17185139789 "hello"`');
|
|
87
|
+
}
|
|
88
|
+
const text = String(message);
|
|
89
|
+
const authUser = Number(ctx.options.authuser) || 0;
|
|
90
|
+
// Unlike list/read, sendsms is gated by reCAPTCHA Enterprise + a BotGuard
|
|
91
|
+
// token that only Google's in-browser JS can mint — so we drive the
|
|
92
|
+
// authenticated page (which produces those tokens) instead of POSTing
|
|
93
|
+
// directly. Requires Chrome running with remote debugging (CDP).
|
|
94
|
+
let page;
|
|
95
|
+
try {
|
|
96
|
+
page = await ctx.browser();
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
throw new Error(`send needs a running Chrome (it can't be done over plain HTTP — sendsms requires reCAPTCHA). ` +
|
|
100
|
+
`Start Chrome with --remote-debugging-port=9222. Cause: ${err instanceof Error ? err.message : String(err)}`);
|
|
101
|
+
}
|
|
102
|
+
await page.goto(threadUrl(String(target), authUser), { waitUntil: "domcontentloaded" });
|
|
103
|
+
const box = page.getByRole("textbox", { name: "Type a message" });
|
|
104
|
+
await box.waitFor({ state: "visible", timeout: 20000 });
|
|
105
|
+
await box.click();
|
|
106
|
+
await box.fill(text);
|
|
107
|
+
const waitForSend = page.waitForResponse((r) => r.url().includes(SEND_SMS_PATH) && r.request().method() === "POST", { timeout: 30000 });
|
|
108
|
+
await page.getByRole("button", { name: "Send message" }).click();
|
|
109
|
+
const resp = await waitForSend;
|
|
110
|
+
const sendBody = JSON.parse(await resp.text());
|
|
111
|
+
if (ctx.debug)
|
|
112
|
+
console.error("[voice] sendsms ->", JSON.stringify(sendBody).slice(0, 160));
|
|
113
|
+
return {
|
|
114
|
+
sent: true,
|
|
115
|
+
to: normalizeNumber(String(target)),
|
|
116
|
+
text,
|
|
117
|
+
...decodeSendResult(sendBody),
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
throw new Error(`Unknown action "${action}". Use one of: list, read, send.`);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Generated by `pnpm generate:openapi` — do not edit by hand.
|
|
2
|
+
openapi: 3.1.0
|
|
3
|
+
info:
|
|
4
|
+
title: Google Voice
|
|
5
|
+
description: List threads and read conversations over HTTP (no browser); send SMS via the attached browser.
|
|
6
|
+
version: 1.1.3
|
|
7
|
+
servers:
|
|
8
|
+
- url: https://voice.google.com
|
|
9
|
+
paths:
|
|
10
|
+
/u/0/messages:
|
|
11
|
+
get:
|
|
12
|
+
summary: "Google Voice: GET /u/0/messages"
|
|
13
|
+
description: List threads and read conversations over HTTP (no browser); send SMS via the attached
|
|
14
|
+
browser.
|
|
15
|
+
operationId: google_voice_get__u_0_messages
|
|
16
|
+
responses:
|
|
17
|
+
"200":
|
|
18
|
+
description: JSON response body (shape defined by the site, see its transform)
|
|
19
|
+
security:
|
|
20
|
+
- chromeSession: []
|
|
21
|
+
components:
|
|
22
|
+
securitySchemes:
|
|
23
|
+
chromeSession:
|
|
24
|
+
type: apiKey
|
|
25
|
+
in: cookie
|
|
26
|
+
name: session
|
|
27
|
+
description: "Authenticated via the user's real Chrome session: website-api injects decrypted Chrome
|
|
28
|
+
cookies for google.com into every request."
|
|
29
|
+
x-website-api:
|
|
30
|
+
id: google-voice
|
|
31
|
+
domain: voice.google.com
|
|
32
|
+
cookieDomain: google.com
|
|
33
|
+
transport: http
|
|
34
|
+
cookies: required
|
|
35
|
+
requiresLogin: true
|
|
36
|
+
imperative: false
|
|
37
|
+
cli:
|
|
38
|
+
command: website-api google-voice
|
|
39
|
+
positionals:
|
|
40
|
+
- name: action
|
|
41
|
+
description: "list | read | send (default: list)"
|
|
42
|
+
required: false
|
|
43
|
+
variadic: false
|
|
44
|
+
- name: target
|
|
45
|
+
description: phone number or thread id (for read/send)
|
|
46
|
+
required: false
|
|
47
|
+
variadic: false
|
|
48
|
+
- name: message
|
|
49
|
+
description: message text to send (for send)
|
|
50
|
+
required: false
|
|
51
|
+
variadic: true
|
|
52
|
+
parameters:
|
|
53
|
+
- flag: --limit
|
|
54
|
+
type: number
|
|
55
|
+
description: max threads to return
|
|
56
|
+
default: 25
|
|
57
|
+
required: false
|
|
58
|
+
- flag: --messages
|
|
59
|
+
type: number
|
|
60
|
+
description: max messages per thread to fetch
|
|
61
|
+
default: 15
|
|
62
|
+
required: false
|
|
63
|
+
- flag: --authuser
|
|
64
|
+
type: number
|
|
65
|
+
description: Google account index in the URL (/u/N/) for send
|
|
66
|
+
default: 0
|
|
67
|
+
required: false
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { CookieEntry } from "chrome-tools";
|
|
2
|
+
/**
|
|
3
|
+
* Google Voice's web app fetches data from a private JSON+protobuf API:
|
|
4
|
+
* POST https://clients6.google.com/voice/v1/voiceclient/<method>?alt=protojson&key=<API_KEY>
|
|
5
|
+
*
|
|
6
|
+
* It can be replayed over plain HTTP (no browser) once you mirror the exact
|
|
7
|
+
* cross-domain handshake the gapi client uses — see the long note in
|
|
8
|
+
* `voiceHeaders()`.
|
|
9
|
+
*/
|
|
10
|
+
export declare const VOICE_API_BASE = "https://clients6.google.com/voice/v1/voiceclient";
|
|
11
|
+
/** Public web-client API key embedded in voice.google.com's JS (not a secret). */
|
|
12
|
+
export declare const VOICE_API_KEY = "AIzaSyDTYc1N4xiODyrQYK0Kl6g_y279LjYkrBg";
|
|
13
|
+
/** Origin the SAPISIDHASH is bound to (the app's real origin). */
|
|
14
|
+
export declare const VOICE_ORIGIN = "https://voice.google.com";
|
|
15
|
+
/** Host that actually serves the API (and that XD3 wants the Origin to match). */
|
|
16
|
+
export declare const CLIENTS6_ORIGIN = "https://clients6.google.com";
|
|
17
|
+
/** Full request URL for a voiceclient method. */
|
|
18
|
+
export declare function voiceUrl(method: string): string;
|
|
19
|
+
/** Substring identifying the send-SMS response (used when driving the browser). */
|
|
20
|
+
export declare const SEND_SMS_PATH = "/voiceclient/api2thread/sendsms";
|
|
21
|
+
/**
|
|
22
|
+
* `sendsms` is gated by reCAPTCHA Enterprise (this site key) + a BotGuard token,
|
|
23
|
+
* both minted only by Google's in-browser anti-bot JS — so `send` can't go over
|
|
24
|
+
* pure HTTP and must drive the authenticated page instead.
|
|
25
|
+
*/
|
|
26
|
+
export declare const RECAPTCHA_SITE_KEY = "6Lfv4cYpAAAAABTKDTZu0jpcgdQ5Ak4XxeUBqA7B";
|
|
27
|
+
export declare const RECAPTCHA_ACTION = "SEND_SMS";
|
|
28
|
+
/** Builds the in-app conversation URL for a given target. */
|
|
29
|
+
export declare function threadUrl(target: string, authUser?: number): string;
|
|
30
|
+
/**
|
|
31
|
+
* Selects the cookies clients6.google.com actually receives: only those scoped
|
|
32
|
+
* to `google.com`, de-duplicated by name. Chrome may hold same-named cookies
|
|
33
|
+
* for sibling domains (e.g. `.youtube.com`) or stale duplicates; sending those
|
|
34
|
+
* alongside — or hashing the wrong `SAPISID` — yields `401 invalid credentials`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function selectGoogleCookies(cookies: CookieEntry[]): {
|
|
37
|
+
header: string;
|
|
38
|
+
byName: Map<string, string>;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Builds the `Authorization` header Google's JS clients send to clients6:
|
|
42
|
+
* `SAPISIDHASH <ts>_<sha1(`<ts> <cookie> <origin>`)> SAPISID1PHASH … SAPISID3PHASH …`
|
|
43
|
+
* Every *APISID cookie present is hashed against the *voice.google.com* origin.
|
|
44
|
+
*/
|
|
45
|
+
export declare function buildSapisidAuth(byName: Map<string, string>, timestampSeconds: number, origin?: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* Headers for a clients6 voiceclient call. The cross-domain ("XD3") trick:
|
|
48
|
+
* • `Origin` must equal the Host (clients6) — the app issues the XHR from
|
|
49
|
+
* gapi's proxy iframe served *from* clients6, so anything else trips
|
|
50
|
+
* `400 "Origin doesn't match Host for XD3"`.
|
|
51
|
+
* • The real caller identity rides in `X-Origin` (= voice.google.com), which
|
|
52
|
+
* is what the SAPISIDHASH is bound to — anything else trips `401`.
|
|
53
|
+
* Those two together are why a naive same-origin request can't work.
|
|
54
|
+
*/
|
|
55
|
+
export declare function voiceHeaders(authorization: string, cookieHeader: string): Record<string, string>;
|
|
56
|
+
/**
|
|
57
|
+
* Normalizes a US-centric phone number to E.164 (`+1XXXXXXXXXX`). Already-prefixed
|
|
58
|
+
* numbers and short codes (e.g. "39769") are passed through untouched.
|
|
59
|
+
*/
|
|
60
|
+
export declare function normalizeNumber(input: string): string;
|
|
61
|
+
/**
|
|
62
|
+
* Resolves a thread id from either a raw thread id (`t.+1718…`, `t.39769`) or a
|
|
63
|
+
* phone number / short code (which the web app maps to `t.<E.164-or-shortcode>`).
|
|
64
|
+
*/
|
|
65
|
+
export declare function toThreadId(target: string): string;
|
|
66
|
+
export interface VoiceMessage {
|
|
67
|
+
id: string;
|
|
68
|
+
timestamp: string;
|
|
69
|
+
from: string | null;
|
|
70
|
+
text: string | null;
|
|
71
|
+
direction: "incoming" | "outgoing" | "unknown";
|
|
72
|
+
read: boolean;
|
|
73
|
+
}
|
|
74
|
+
export interface VoiceThread {
|
|
75
|
+
threadId: string;
|
|
76
|
+
contact: string | null;
|
|
77
|
+
unread: boolean;
|
|
78
|
+
latestText: string | null;
|
|
79
|
+
latestTimestamp: string | null;
|
|
80
|
+
messageCount: number;
|
|
81
|
+
messages: VoiceMessage[];
|
|
82
|
+
}
|
|
83
|
+
/** Decodes one message tuple from an `api2thread/list` thread. */
|
|
84
|
+
export declare function decodeMessage(m: any[]): VoiceMessage;
|
|
85
|
+
/** Decodes one thread tuple, newest message first (as the API returns it). */
|
|
86
|
+
export declare function decodeThread(t: any[]): VoiceThread;
|
|
87
|
+
/** Decodes a full `api2thread/list` response body into threads. */
|
|
88
|
+
export declare function decodeThreadList(body: any): VoiceThread[];
|
|
89
|
+
/**
|
|
90
|
+
* Body for `api2thread/list`: `[folder, threadLimit, perThreadLimit, null, null, [null,1,1,1]]`.
|
|
91
|
+
* `folder` 2 is the SMS/text inbox the web app loads by default.
|
|
92
|
+
*/
|
|
93
|
+
export declare function buildListBody(threadLimit: number, perThreadLimit: number): unknown[];
|
|
94
|
+
/**
|
|
95
|
+
* Body for `api2thread/sendsms`:
|
|
96
|
+
* `[null,null,null,null, text, threadId, null, null, [transactionId]]`
|
|
97
|
+
* `transactionId` is a client idempotency id; any large positive integer works.
|
|
98
|
+
*/
|
|
99
|
+
export declare function buildSendBody(text: string, threadId: string, transactionId: number): unknown[];
|
|
100
|
+
/** Decodes an `api2thread/sendsms` response into the sent-message summary. */
|
|
101
|
+
export declare function decodeSendResult(body: any): {
|
|
102
|
+
threadId: string | null;
|
|
103
|
+
messageId: string | null;
|
|
104
|
+
timestamp: string | null;
|
|
105
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Google Voice's web app fetches data from a private JSON+protobuf API:
|
|
4
|
+
* POST https://clients6.google.com/voice/v1/voiceclient/<method>?alt=protojson&key=<API_KEY>
|
|
5
|
+
*
|
|
6
|
+
* It can be replayed over plain HTTP (no browser) once you mirror the exact
|
|
7
|
+
* cross-domain handshake the gapi client uses — see the long note in
|
|
8
|
+
* `voiceHeaders()`.
|
|
9
|
+
*/
|
|
10
|
+
export const VOICE_API_BASE = "https://clients6.google.com/voice/v1/voiceclient";
|
|
11
|
+
/** Public web-client API key embedded in voice.google.com's JS (not a secret). */
|
|
12
|
+
export const VOICE_API_KEY = "AIzaSyDTYc1N4xiODyrQYK0Kl6g_y279LjYkrBg"; // secret-scan: allow
|
|
13
|
+
/** Origin the SAPISIDHASH is bound to (the app's real origin). */
|
|
14
|
+
export const VOICE_ORIGIN = "https://voice.google.com";
|
|
15
|
+
/** Host that actually serves the API (and that XD3 wants the Origin to match). */
|
|
16
|
+
export const CLIENTS6_ORIGIN = "https://clients6.google.com";
|
|
17
|
+
/** Full request URL for a voiceclient method. */
|
|
18
|
+
export function voiceUrl(method) {
|
|
19
|
+
return `${VOICE_API_BASE}/${method}?alt=protojson&key=${VOICE_API_KEY}`;
|
|
20
|
+
}
|
|
21
|
+
/** Substring identifying the send-SMS response (used when driving the browser). */
|
|
22
|
+
export const SEND_SMS_PATH = "/voiceclient/api2thread/sendsms";
|
|
23
|
+
/**
|
|
24
|
+
* `sendsms` is gated by reCAPTCHA Enterprise (this site key) + a BotGuard token,
|
|
25
|
+
* both minted only by Google's in-browser anti-bot JS — so `send` can't go over
|
|
26
|
+
* pure HTTP and must drive the authenticated page instead.
|
|
27
|
+
*/
|
|
28
|
+
export const RECAPTCHA_SITE_KEY = "6Lfv4cYpAAAAABTKDTZu0jpcgdQ5Ak4XxeUBqA7B";
|
|
29
|
+
export const RECAPTCHA_ACTION = "SEND_SMS";
|
|
30
|
+
/** Builds the in-app conversation URL for a given target. */
|
|
31
|
+
export function threadUrl(target, authUser = 0) {
|
|
32
|
+
return `https://voice.google.com/u/${authUser}/messages?itemId=${encodeURIComponent(toThreadId(target))}`;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Selects the cookies clients6.google.com actually receives: only those scoped
|
|
36
|
+
* to `google.com`, de-duplicated by name. Chrome may hold same-named cookies
|
|
37
|
+
* for sibling domains (e.g. `.youtube.com`) or stale duplicates; sending those
|
|
38
|
+
* alongside — or hashing the wrong `SAPISID` — yields `401 invalid credentials`.
|
|
39
|
+
*/
|
|
40
|
+
export function selectGoogleCookies(cookies) {
|
|
41
|
+
const byName = new Map();
|
|
42
|
+
for (const c of cookies) {
|
|
43
|
+
const domain = (c.domain || "").replace(/^\./, "");
|
|
44
|
+
if (domain !== "google.com")
|
|
45
|
+
continue;
|
|
46
|
+
byName.set(c.name, c.value); // last write wins (deduped)
|
|
47
|
+
}
|
|
48
|
+
const header = [...byName].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
49
|
+
return { header, byName };
|
|
50
|
+
}
|
|
51
|
+
/** Cookies whose SHA-1 digests make up the multi-part Authorization header. */
|
|
52
|
+
const SAPISID_HASHES = [
|
|
53
|
+
["SAPISIDHASH", "SAPISID"],
|
|
54
|
+
["SAPISID1PHASH", "__Secure-1PAPISID"],
|
|
55
|
+
["SAPISID3PHASH", "__Secure-3PAPISID"],
|
|
56
|
+
];
|
|
57
|
+
/**
|
|
58
|
+
* Builds the `Authorization` header Google's JS clients send to clients6:
|
|
59
|
+
* `SAPISIDHASH <ts>_<sha1(`<ts> <cookie> <origin>`)> SAPISID1PHASH … SAPISID3PHASH …`
|
|
60
|
+
* Every *APISID cookie present is hashed against the *voice.google.com* origin.
|
|
61
|
+
*/
|
|
62
|
+
export function buildSapisidAuth(byName, timestampSeconds, origin = VOICE_ORIGIN) {
|
|
63
|
+
const parts = [];
|
|
64
|
+
for (const [label, cookieName] of SAPISID_HASHES) {
|
|
65
|
+
const cookie = byName.get(cookieName);
|
|
66
|
+
if (!cookie)
|
|
67
|
+
continue;
|
|
68
|
+
const digest = createHash("sha1").update(`${timestampSeconds} ${cookie} ${origin}`).digest("hex");
|
|
69
|
+
parts.push(`${label} ${timestampSeconds}_${digest}`);
|
|
70
|
+
}
|
|
71
|
+
if (parts.length === 0) {
|
|
72
|
+
throw new Error("No Google session cookies (SAPISID) found for google.com. Make sure Chrome is logged into voice.google.com " +
|
|
73
|
+
"(and that PROFILE_PATH / --profile points at that profile).");
|
|
74
|
+
}
|
|
75
|
+
return parts.join(" ");
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Headers for a clients6 voiceclient call. The cross-domain ("XD3") trick:
|
|
79
|
+
* • `Origin` must equal the Host (clients6) — the app issues the XHR from
|
|
80
|
+
* gapi's proxy iframe served *from* clients6, so anything else trips
|
|
81
|
+
* `400 "Origin doesn't match Host for XD3"`.
|
|
82
|
+
* • The real caller identity rides in `X-Origin` (= voice.google.com), which
|
|
83
|
+
* is what the SAPISIDHASH is bound to — anything else trips `401`.
|
|
84
|
+
* Those two together are why a naive same-origin request can't work.
|
|
85
|
+
*/
|
|
86
|
+
export function voiceHeaders(authorization, cookieHeader) {
|
|
87
|
+
return {
|
|
88
|
+
authorization,
|
|
89
|
+
"content-type": "application/json+protobuf",
|
|
90
|
+
origin: CLIENTS6_ORIGIN,
|
|
91
|
+
referer: `${CLIENTS6_ORIGIN}/static/proxy.html?usegapi=1`,
|
|
92
|
+
"x-origin": VOICE_ORIGIN,
|
|
93
|
+
"x-referer": VOICE_ORIGIN,
|
|
94
|
+
"x-goog-authuser": "0",
|
|
95
|
+
"x-requested-with": "XMLHttpRequest",
|
|
96
|
+
"x-javascript-user-agent": "google-api-javascript-client/1.1.0",
|
|
97
|
+
"x-goog-encode-response-if-executable": "base64",
|
|
98
|
+
Cookie: cookieHeader,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Normalizes a US-centric phone number to E.164 (`+1XXXXXXXXXX`). Already-prefixed
|
|
103
|
+
* numbers and short codes (e.g. "39769") are passed through untouched.
|
|
104
|
+
*/
|
|
105
|
+
export function normalizeNumber(input) {
|
|
106
|
+
const trimmed = input.trim();
|
|
107
|
+
if (trimmed.startsWith("+"))
|
|
108
|
+
return trimmed;
|
|
109
|
+
const digits = trimmed.replace(/\D/g, "");
|
|
110
|
+
if (digits.length === 10)
|
|
111
|
+
return `+1${digits}`;
|
|
112
|
+
if (digits.length === 11 && digits.startsWith("1"))
|
|
113
|
+
return `+${digits}`;
|
|
114
|
+
return digits; // short code or already-bare id
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Resolves a thread id from either a raw thread id (`t.+1718…`, `t.39769`) or a
|
|
118
|
+
* phone number / short code (which the web app maps to `t.<E.164-or-shortcode>`).
|
|
119
|
+
*/
|
|
120
|
+
export function toThreadId(target) {
|
|
121
|
+
if (target.startsWith("t."))
|
|
122
|
+
return target;
|
|
123
|
+
return `t.${normalizeNumber(target)}`;
|
|
124
|
+
}
|
|
125
|
+
const DIRECTION = { 5: "incoming", 6: "outgoing" };
|
|
126
|
+
/** Decodes one message tuple from an `api2thread/list` thread. */
|
|
127
|
+
export function decodeMessage(m) {
|
|
128
|
+
return {
|
|
129
|
+
id: m[0],
|
|
130
|
+
timestamp: m[1] ? new Date(m[1]).toISOString() : "",
|
|
131
|
+
from: Array.isArray(m[3]) ? (m[3][0] ?? null) : null,
|
|
132
|
+
text: m[9] ?? null,
|
|
133
|
+
direction: DIRECTION[m[12]] ?? "unknown",
|
|
134
|
+
// Incoming messages carry a read flag at index 5 (1 = read, 0 = unread);
|
|
135
|
+
// outgoing messages are always considered read.
|
|
136
|
+
read: m[12] === 6 ? true : m[5] === 1,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/** Decodes one thread tuple, newest message first (as the API returns it). */
|
|
140
|
+
export function decodeThread(t) {
|
|
141
|
+
const messages = (Array.isArray(t[2]) ? t[2] : []).map(decodeMessage);
|
|
142
|
+
const contact = Array.isArray(t[8]) ? (t[8][0] ?? null) : (t[0]?.replace(/^t\./, "") ?? null);
|
|
143
|
+
const latestIncoming = messages.find((msg) => msg.direction === "incoming");
|
|
144
|
+
return {
|
|
145
|
+
threadId: t[0],
|
|
146
|
+
contact,
|
|
147
|
+
unread: latestIncoming ? !latestIncoming.read : false,
|
|
148
|
+
latestText: messages[0]?.text ?? null,
|
|
149
|
+
latestTimestamp: messages[0]?.timestamp ?? null,
|
|
150
|
+
messageCount: messages.length,
|
|
151
|
+
messages,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/** Decodes a full `api2thread/list` response body into threads. */
|
|
155
|
+
export function decodeThreadList(body) {
|
|
156
|
+
const threads = Array.isArray(body?.[0]) ? body[0] : [];
|
|
157
|
+
return threads.map(decodeThread);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Body for `api2thread/list`: `[folder, threadLimit, perThreadLimit, null, null, [null,1,1,1]]`.
|
|
161
|
+
* `folder` 2 is the SMS/text inbox the web app loads by default.
|
|
162
|
+
*/
|
|
163
|
+
export function buildListBody(threadLimit, perThreadLimit) {
|
|
164
|
+
return [2, threadLimit, perThreadLimit, null, null, [null, 1, 1, 1]];
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Body for `api2thread/sendsms`:
|
|
168
|
+
* `[null,null,null,null, text, threadId, null, null, [transactionId]]`
|
|
169
|
+
* `transactionId` is a client idempotency id; any large positive integer works.
|
|
170
|
+
*/
|
|
171
|
+
export function buildSendBody(text, threadId, transactionId) {
|
|
172
|
+
return [null, null, null, null, text, threadId, null, null, [transactionId]];
|
|
173
|
+
}
|
|
174
|
+
/** Decodes an `api2thread/sendsms` response into the sent-message summary. */
|
|
175
|
+
export function decodeSendResult(body) {
|
|
176
|
+
return {
|
|
177
|
+
threadId: body?.[1] ?? null,
|
|
178
|
+
messageId: body?.[2] ?? null,
|
|
179
|
+
timestamp: body?.[3] ? new Date(body[3]).toISOString() : null,
|
|
180
|
+
};
|
|
181
|
+
}
|