diff --git a/README.md b/README.md index 1dab6f3..43c712c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - 支持搜索、筛选、排序以及桌面表格和移动端卡片。 - 从任意地区一键带入国家和货币,生成 ChatGPT Team 结账脚本。 - 脚本生成器支持 39 种货币、实时预览、复制和 `.js` 下载。 -- 优惠码仅在当前浏览器内处理,不写入网址或本地存储。 +- 优惠码和手动提供的 Token 仅在当前浏览器内处理,不写入网址或本地存储。 - GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。 - 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。 @@ -59,7 +59,9 @@ npm run build 顶部导航可切换到脚本生成器,也可以从价格表或移动端价格卡片直接进入。地区入口会通过 `view=generator&country=XX¤cy=XXX` 预填国家和货币,优惠码不会出现在网址中。 -生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`。它只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准。 +生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`。Access Token 可以在脚本运行时从登录 Session 自动获取,也可以手动粘贴原始 `accessToken` 或 `/api/auth/session` 返回的完整 JSON;完整 JSON 只会提取其中的 `accessToken` 写入脚本。切回自动获取时,手动输入会立即清空。 + +生成器只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准。 ### 发布保护 diff --git a/src/App.test.tsx b/src/App.test.tsx index ccdb197..b5c472b 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import sample from "../public/data/sample-prices.json"; @@ -134,4 +134,38 @@ describe("pricing explorer", () => { expect(screen.getByLabelText(/国家 ISO 缩写/)).toHaveValue("US"); expect(screen.getByLabelText(/货币 ISO 缩写/)).toHaveValue("EGP"); }); + + it("accepts complete session JSON in manual token mode without storing it", async () => { + const user = userEvent.setup(); + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + render(); + await user.click(screen.getByRole("button", { name: /脚本生成器/ })); + + expect(screen.getByRole("radio", { name: /自动获取/ })).toBeChecked(); + await user.click(screen.getByRole("radio", { name: /手动粘贴/ })); + const tokenInput = screen.getByPlaceholderText("粘贴 accessToken 或完整 Session JSON"); + const sessionJson = JSON.stringify({ + user: { email: "private@example.com" }, + accessToken: "session-access-token", + }); + fireEvent.change(tokenInput, { target: { value: sessionJson } }); + await user.type(screen.getByPlaceholderText("例如:XXXXXXXXXXXX"), "SAVE20"); + await user.click(screen.getByRole("button", { name: /复制代码/ })); + + const copiedScript = writeText.mock.calls[0][0] as string; + expect(copiedScript).toContain('const accessToken = "session-access-token"'); + expect(copiedScript).not.toContain("private@example.com"); + expect(copiedScript).not.toContain("/api/auth/session"); + expect(window.location.search).not.toContain("session-access-token"); + expect(Object.values(window.localStorage)).not.toContain("session-access-token"); + + await user.click(screen.getByRole("radio", { name: /自动获取/ })); + expect(screen.queryByPlaceholderText("粘贴 accessToken 或完整 Session JSON")).not.toBeInTheDocument(); + await user.click(screen.getByRole("radio", { name: /手动粘贴/ })); + expect(screen.getByPlaceholderText("粘贴 accessToken 或完整 Session JSON")).toHaveValue(""); + }); }); diff --git a/src/CheckoutGenerator.tsx b/src/CheckoutGenerator.tsx index a024856..0d3daba 100644 --- a/src/CheckoutGenerator.tsx +++ b/src/CheckoutGenerator.tsx @@ -12,6 +12,7 @@ import { import { useEffect, useMemo, useRef, useState } from "react"; import { CHECKOUT_CURRENCIES, + DEFAULT_ACCESS_TOKEN_MODE, DEFAULT_CHECKOUT_COUNTRY, DEFAULT_CHECKOUT_CURRENCY, generateCheckoutScript, @@ -19,6 +20,7 @@ import { normalizeIsoInput, validateCheckoutInput, type CheckoutScriptInput, + type CheckoutInputField, type CheckoutValidationErrors, } from "./checkout-generator"; import type { PriceRow } from "./types"; @@ -41,12 +43,15 @@ export default function CheckoutGenerator({ const [coupon, setCoupon] = useState(""); const [country, setCountry] = useState(initialCountry); const [currency, setCurrency] = useState(initialCurrency); + const [accessTokenMode, setAccessTokenMode] = useState<"auto" | "manual">(DEFAULT_ACCESS_TOKEN_MODE); + const [accessToken, setAccessToken] = useState(""); const [errors, setErrors] = useState({}); const [notice, setNotice] = useState(""); const [copied, setCopied] = useState(false); const couponRef = useRef(null); const countryRef = useRef(null); const currencyRef = useRef(null); + const accessTokenRef = useRef(null); useEffect(() => { setCountry(initialCountry || DEFAULT_CHECKOUT_COUNTRY); @@ -64,6 +69,8 @@ export default function CheckoutGenerator({ coupon: coupon.trim(), country: country.toUpperCase(), currency: currency.toUpperCase(), + accessTokenMode, + accessToken: accessToken.trim(), }; const previewInput: CheckoutScriptInput = { coupon: normalized.coupon || "XXXXXXXXXXXX", @@ -73,15 +80,25 @@ export default function CheckoutGenerator({ currency: isSupportedCheckoutCurrency(normalized.currency) ? normalized.currency : DEFAULT_CHECKOUT_CURRENCY, + accessTokenMode, + accessToken: accessTokenMode === "manual" + ? normalized.accessToken || "PASTE_ACCESS_TOKEN_HERE" + : "", }; const source = useMemo( () => generateCheckoutScript(previewInput), - [previewInput.coupon, previewInput.country, previewInput.currency], + [ + previewInput.coupon, + previewInput.country, + previewInput.currency, + previewInput.accessTokenMode, + previewInput.accessToken, + ], ); const isInputValid = Object.keys(validateCheckoutInput(normalized)).length === 0; const currencyName = CHECKOUT_CURRENCIES.find(([code]) => code === normalized.currency)?.[1]; - const clearError = (field: keyof CheckoutScriptInput) => { + const clearError = (field: CheckoutInputField) => { if (!errors[field]) return; setErrors((current) => ({ ...current, [field]: undefined })); }; @@ -89,10 +106,11 @@ export default function CheckoutGenerator({ const validateAndFocus = () => { const nextErrors = validateCheckoutInput(normalized); setErrors(nextErrors); - const firstError = Object.keys(nextErrors)[0] as keyof CheckoutScriptInput | undefined; + const firstError = Object.keys(nextErrors)[0] as CheckoutInputField | undefined; if (firstError === "coupon") couponRef.current?.focus(); if (firstError === "country") countryRef.current?.focus(); if (firstError === "currency") currencyRef.current?.focus(); + if (firstError === "accessToken") accessTokenRef.current?.focus(); return !firstError; }; @@ -148,8 +166,10 @@ export default function CheckoutGenerator({ setCoupon(""); setCountry(DEFAULT_CHECKOUT_COUNTRY); setCurrency(DEFAULT_CHECKOUT_CURRENCY); + setAccessTokenMode(DEFAULT_ACCESS_TOKEN_MODE); + setAccessToken(""); setErrors({}); - setNotice("已恢复默认值 US / EGP"); + setNotice("已恢复自动获取与默认值 US / EGP"); couponRef.current?.focus(); }; @@ -162,11 +182,11 @@ export default function CheckoutGenerator({ BUSINESS TOOL · GENERATOR 参数填好,脚本即刻就绪。 - 输入优惠码、国家和货币,生成可复制的 ChatGPT Team 结账脚本。全部处理都在当前浏览器中完成。 + 选择自动获取或手动提供 Access Token,再输入优惠码、国家和货币,生成可复制的 ChatGPT Team 结账脚本。 - 本地生成不上传、不保存优惠码,也不会在本站执行脚本。 + 本地生成不上传、不保存优惠码或 Access Token,也不会在本站执行脚本。 @@ -192,6 +212,56 @@ export default function CheckoutGenerator({ {errors.coupon ? {errors.coupon} : null} + + Access Token 来源 + + + { + setAccessTokenMode("auto"); + setAccessToken(""); + clearError("accessToken"); + }} + /> + 自动获取运行时读取登录 Session + + + { + setAccessTokenMode("manual"); + clearError("accessToken"); + }} + /> + 手动粘贴支持 Token 或 Session JSON + + + + + {accessTokenMode === "manual" ? ( + + Access Token / Session JSON仅用于当前生成结果 + { setAccessToken(event.target.value); clearError("accessToken"); }} + placeholder="粘贴 accessToken 或完整 Session JSON" + autoComplete="off" + spellCheck={false} + aria-invalid={Boolean(errors.accessToken)} + /> + {errors.accessToken ? {errors.accessToken} : Session JSON 会自动提取 accessToken;切换回自动获取时立即清空} + + ) : null} + 国家 ISO 缩写2 位字母 @@ -248,6 +318,7 @@ export default function CheckoutGenerator({ 如何使用 + 选择自动获取,或手动粘贴 Access Token。 登录 ChatGPT 并打开浏览器控制台。 复制生成的脚本,粘贴后执行。 根据控制台输出查看 Stripe 长链接。 diff --git a/src/checkout-generator.test.ts b/src/checkout-generator.test.ts index 442c0b7..c80a375 100644 --- a/src/checkout-generator.test.ts +++ b/src/checkout-generator.test.ts @@ -3,6 +3,7 @@ import { CHECKOUT_CURRENCIES, DEFAULT_CHECKOUT_COUNTRY, DEFAULT_CHECKOUT_CURRENCY, + extractAccessToken, generateCheckoutScript, isSupportedCheckoutCurrency, normalizeIsoInput, @@ -20,7 +21,13 @@ describe("checkout script generator", () => { it("normalizes ISO input and validates every field", () => { expect(normalizeIsoInput(" u-s1 ", 2)).toBe("US"); - expect(validateCheckoutInput({ coupon: "", country: "USA", currency: "TRY" })) + expect(validateCheckoutInput({ + coupon: "", + country: "USA", + currency: "TRY", + accessTokenMode: "auto", + accessToken: "", + })) .toEqual({ coupon: "请输入优惠码", country: "请输入 2 位英文字母国家代码", @@ -33,6 +40,8 @@ describe("checkout script generator", () => { coupon: 'SAVE "20" & 更多', country: "SG", currency: "SGD", + accessTokenMode: "auto", + accessToken: "", }); expect(script).toContain('const COUPON = "SAVE \\"20\\" & 更多"'); @@ -42,4 +51,44 @@ describe("checkout script generator", () => { expect(script).toContain("promoCode=SAVE%20%2220%22%20%26%20%E6%9B%B4%E5%A4%9A"); expect(() => new Function(script)).not.toThrow(); }); + + it("accepts a raw access token or extracts one from complete session JSON", () => { + expect(extractAccessToken(" eyJraw.token ")).toBe("eyJraw.token"); + expect(extractAccessToken(JSON.stringify({ user: { name: "Kai" }, accessToken: "eyJsession.token" }))) + .toBe("eyJsession.token"); + expect(extractAccessToken(JSON.stringify({ session: { access_token: "nested-token" } }))) + .toBe("nested-token"); + expect(extractAccessToken('{"user":true}')).toBeNull(); + expect(extractAccessToken("{")).toBeNull(); + }); + + it("writes only the extracted token in manual mode and removes the session fetch", () => { + const sessionJson = JSON.stringify({ + user: { email: "private@example.com" }, + accessToken: 'eyJmanual."token"', + expires: "2099-01-01", + }); + const script = generateCheckoutScript({ + coupon: "SAVE20", + country: "US", + currency: "EGP", + accessTokenMode: "manual", + accessToken: sessionJson, + }); + + expect(script).toContain('const accessToken = "eyJmanual.\\"token\\""'); + expect(script).not.toContain("/api/auth/session"); + expect(script).not.toContain("private@example.com"); + expect(() => new Function(script)).not.toThrow(); + }); + + it("requires an extractable token in manual mode", () => { + expect(validateCheckoutInput({ + coupon: "SAVE20", + country: "US", + currency: "EGP", + accessTokenMode: "manual", + accessToken: '{"user":true}', + })).toEqual({ accessToken: "未能从输入内容中提取 accessToken" }); + }); }); diff --git a/src/checkout-generator.ts b/src/checkout-generator.ts index 1b68390..fb66500 100644 --- a/src/checkout-generator.ts +++ b/src/checkout-generator.ts @@ -42,16 +42,22 @@ export const CHECKOUT_CURRENCIES = [ export const DEFAULT_CHECKOUT_COUNTRY = "US"; export const DEFAULT_CHECKOUT_CURRENCY = "EGP"; +export const DEFAULT_ACCESS_TOKEN_MODE = "auto" as const; const currencyCodes = new Set(CHECKOUT_CURRENCIES.map(([code]) => code)); +export type AccessTokenMode = "auto" | "manual"; + export type CheckoutScriptInput = { coupon: string; country: string; currency: string; + accessTokenMode: AccessTokenMode; + accessToken: string; }; -export type CheckoutValidationErrors = Partial>; +export type CheckoutInputField = "coupon" | "country" | "currency" | "accessToken"; +export type CheckoutValidationErrors = Partial>; export function normalizeIsoInput(value: string, maxLength: number): string { return value.toUpperCase().replace(/[^A-Z]/g, "").slice(0, maxLength); @@ -61,11 +67,45 @@ export function isSupportedCheckoutCurrency(value: string): boolean { return currencyCodes.has(value.toUpperCase()); } +function findAccessToken(value: unknown, depth = 0): string | null { + if (depth > 4 || value == null || typeof value !== "object") return null; + for (const [key, child] of Object.entries(value)) { + if ((key === "accessToken" || key === "access_token") && typeof child === "string" && child.trim()) { + return child.trim(); + } + } + for (const child of Object.values(value)) { + const token = findAccessToken(child, depth + 1); + if (token) return token; + } + return null; +} + +export function extractAccessToken(value: string): string | null { + const trimmed = value.trim(); + if (!trimmed) return null; + if (!trimmed.startsWith("{") && !trimmed.startsWith("[") && !trimmed.startsWith('"')) { + return trimmed; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === "string") return parsed.trim() || null; + return findAccessToken(parsed); + } catch { + return null; + } +} + export function validateCheckoutInput(input: CheckoutScriptInput): CheckoutValidationErrors { const errors: CheckoutValidationErrors = {}; if (!input.coupon.trim()) errors.coupon = "请输入优惠码"; if (!/^[A-Z]{2}$/.test(input.country)) errors.country = "请输入 2 位英文字母国家代码"; if (!isSupportedCheckoutCurrency(input.currency)) errors.currency = "请选择支持的货币代码"; + if (input.accessTokenMode === "manual" && !input.accessToken.trim()) { + errors.accessToken = "请输入 Access Token 或 Session JSON"; + } else if (input.accessTokenMode === "manual" && !extractAccessToken(input.accessToken)) { + errors.accessToken = "未能从输入内容中提取 accessToken"; + } return errors; } @@ -74,17 +114,19 @@ export function generateCheckoutScript(input: CheckoutScriptInput): string { const country = input.country || DEFAULT_CHECKOUT_COUNTRY; const currency = input.currency || DEFAULT_CHECKOUT_CURRENCY; const encodedCoupon = encodeURIComponent(coupon); - - return `(async function generateAUTeamLink() { - // ================= 配置项 ================= - const WORKSPACE_NAME = "xxx"; - const COUPON = ${JSON.stringify(coupon)}; // 优惠码 - const SEAT_QUANTITY = 2; // 席位数量(Team 最少 2 个) - // ========================================== - - console.log("⏳ 正在获取 ChatGPT Session Token..."); - - // 1. 自动获取登录凭证 + const tokenStatusMessage = input.accessTokenMode === "manual" + ? "⏳ 正在使用手动 Access Token..." + : "⏳ 正在获取 ChatGPT Session Token..."; + const manualAccessToken = extractAccessToken(input.accessToken); + const tokenSource = input.accessTokenMode === "manual" + ? ` // 1. 使用手动提供的登录凭证 + const accessToken = ${JSON.stringify(manualAccessToken || "PASTE_ACCESS_TOKEN_HERE")}; + if (!accessToken || accessToken === "PASTE_ACCESS_TOKEN_HERE") { + console.error("❌ Access Token 为空,请重新生成并填入 Token"); + return; + } + console.log("✅ 已使用手动 Access Token");` + : ` // 1. 自动获取登录凭证 let accessToken; try { const s = await fetch("/api/auth/session").then(r => r.json()); @@ -94,7 +136,18 @@ export function generateCheckoutScript(input: CheckoutScriptInput): string { console.error("❌ 获取 Token 失败:", e.message); return; } - console.log("✅ Token 获取成功"); + console.log("✅ Token 获取成功");`; + + return `(async function generateAUTeamLink() { + // ================= 配置项 ================= + const WORKSPACE_NAME = "xxx"; + const COUPON = ${JSON.stringify(coupon)}; // 优惠码 + const SEAT_QUANTITY = 2; // 席位数量(Team 最少 2 个) + // ========================================== + + console.log(${JSON.stringify(tokenStatusMessage)}); + +${tokenSource} // 2. 构建请求 Payload const payload = { diff --git a/src/styles.css b/src/styles.css index 669ec9a..6999c4f 100644 --- a/src/styles.css +++ b/src/styles.css @@ -171,6 +171,19 @@ tbody tr:hover { background: #f9fbf8; } .generator-field > input:focus, .iso-input:focus-within { border-color: var(--green); box-shadow: 0 0 0 3px rgba(23,107,77,.1); } .generator-field.has-error > input, .generator-field.has-error .iso-input { border-color: #bd5b4f; } .generator-field > em { display: block; margin-top: 7px; color: #a74338; font-size: 9px; font-style: normal; } +.token-source-field { margin: 0 0 18px; padding: 0; border: 0; } +.token-source-field legend { margin-bottom: 8px; font-size: 12px; font-weight: 750; } +.token-source-options { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.token-source-options label { position: relative; display: flex; min-height: 61px; align-items: center; padding: 11px 12px; border: 1px solid #d7ddd7; border-radius: 11px; background: white; cursor: pointer; transition: border-color .18s ease, background .18s ease, box-shadow .18s ease; } +.token-source-options label.active { border-color: var(--green); background: #f1f7f1; box-shadow: 0 0 0 2px rgba(23,107,77,.07); } +.token-source-options input { position: absolute; width: 1px; height: 1px; opacity: 0; } +.token-source-options label::before { width: 13px; height: 13px; flex: 0 0 auto; margin-right: 9px; border: 1px solid #aab5ae; border-radius: 50%; background: white; content: ""; box-shadow: inset 0 0 0 3px white; } +.token-source-options label.active::before { border-color: var(--green); background: var(--green); } +.token-source-options span { display: flex; min-width: 0; flex-direction: column; gap: 4px; } +.token-source-options strong { font-size: 10px; } +.token-source-options small { color: #87918d; font-size: 8px; line-height: 1.4; } +.token-value-field { margin-top: -2px; } +.token-value-field > input { font: 600 11px/1.4 "SFMono-Regular", Consolas, monospace; } .generator-field-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .iso-input { display: grid; grid-template-columns: auto 1fr; align-items: center; } .iso-input b { padding-left: 12px; color: #87918d; font: 700 9px/1 monospace; } @@ -304,6 +317,7 @@ footer > span:last-child { text-align: right; } .section-heading p { display: none; } .generator-form-panel, .generator-result-panel { padding: 22px 16px; } .generator-field-grid { grid-template-columns: 1fr; gap: 0; } + .token-source-options { grid-template-columns: 1fr; } .result-head { align-items: flex-end; } .code-actions button:first-child { display: none; } .code-preview pre { padding: 16px; }
输入优惠码、国家和货币,生成可复制的 ChatGPT Team 结账脚本。全部处理都在当前浏览器中完成。
选择自动获取或手动提供 Access Token,再输入优惠码、国家和货币,生成可复制的 ChatGPT Team 结账脚本。