This commit is contained in:
Kaihua
2026-07-30 23:23:19 +08:00
parent 04df2e0fed
commit 17d8dc20eb
6 changed files with 246 additions and 23 deletions
+4 -2
View File
@@ -12,7 +12,7 @@
- 支持搜索、筛选、排序以及桌面表格和移动端卡片。
- 从任意地区一键带入国家和货币,生成 ChatGPT Team 结账脚本。
- 脚本生成器支持 39 种货币、实时预览、复制和 `.js` 下载。
- 优惠码仅在当前浏览器内处理,不写入网址或本地存储。
- 优惠码和手动提供的 Token 仅在当前浏览器内处理,不写入网址或本地存储。
- GitHub Actions 每两天刷新,结构异常或覆盖率骤降时停止部署。
- 单个地区暂时失败时,最多沿用 14 天的上次成功结果并标记为“数据暂旧”。
@@ -59,7 +59,9 @@ npm run build
顶部导航可切换到脚本生成器,也可以从价格表或移动端价格卡片直接进入。地区入口会通过 `view=generator&country=XX&currency=XXX` 预填国家和货币,优惠码不会出现在网址中。
生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`它只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准
生成器默认使用 `US / EGP`、月付、两个席位和工作区名称 `xxx`Access Token 可以在脚本运行时从登录 Session 自动获取,也可以手动粘贴原始 `accessToken``/api/auth/session` 返回的完整 JSON;完整 JSON 只会提取其中的 `accessToken` 写入脚本。切回自动获取时,手动输入会立即清空
生成器只生成文本,不会在本站请求登录凭证、支付接口或代替用户执行代码。请仅在有权操作的账号中使用,并以实际结账页结果为准。
### 发布保护
+35 -1
View File
@@ -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(<App />);
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("");
});
});
+77 -6
View File
@@ -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<CheckoutValidationErrors>({});
const [notice, setNotice] = useState("");
const [copied, setCopied] = useState(false);
const couponRef = useRef<HTMLInputElement>(null);
const countryRef = useRef<HTMLInputElement>(null);
const currencyRef = useRef<HTMLInputElement>(null);
const accessTokenRef = useRef<HTMLInputElement>(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({
</button>
<span className="eyebrow"><Code2 size={15} /> BUSINESS TOOL · GENERATOR</span>
<h1><br /><em></em></h1>
<p> ChatGPT Team </p>
<p> Access Token ChatGPT Team </p>
</div>
<div className="generator-promise">
<ShieldCheck size={22} />
<div><strong></strong><span></span></div>
<div><strong></strong><span> Access Token</span></div>
</div>
</section>
@@ -192,6 +212,56 @@ export default function CheckoutGenerator({
{errors.coupon ? <em role="alert">{errors.coupon}</em> : null}
</label>
<fieldset className="token-source-field">
<legend>Access Token </legend>
<div className="token-source-options">
<label className={accessTokenMode === "auto" ? "active" : ""}>
<input
type="radio"
name="access-token-mode"
value="auto"
checked={accessTokenMode === "auto"}
onChange={() => {
setAccessTokenMode("auto");
setAccessToken("");
clearError("accessToken");
}}
/>
<span><strong></strong><small> Session</small></span>
</label>
<label className={accessTokenMode === "manual" ? "active" : ""}>
<input
type="radio"
name="access-token-mode"
value="manual"
checked={accessTokenMode === "manual"}
onChange={() => {
setAccessTokenMode("manual");
clearError("accessToken");
}}
/>
<span><strong></strong><small> Token Session JSON</small></span>
</label>
</div>
</fieldset>
{accessTokenMode === "manual" ? (
<label className={`generator-field token-value-field ${errors.accessToken ? "has-error" : ""}`}>
<span><strong>Access Token / Session JSON</strong><small></small></span>
<input
ref={accessTokenRef}
type="password"
value={accessToken}
onChange={(event) => { setAccessToken(event.target.value); clearError("accessToken"); }}
placeholder="粘贴 accessToken 或完整 Session JSON"
autoComplete="off"
spellCheck={false}
aria-invalid={Boolean(errors.accessToken)}
/>
{errors.accessToken ? <em role="alert">{errors.accessToken}</em> : <small className="field-note">Session JSON accessToken</small>}
</label>
) : null}
<div className="generator-field-grid">
<label className={`generator-field ${errors.country ? "has-error" : ""}`}>
<span><strong> ISO </strong><small>2 </small></span>
@@ -248,6 +318,7 @@ export default function CheckoutGenerator({
<div className="generator-steps">
<span>使</span>
<ol>
<li> Access Token</li>
<li> ChatGPT </li>
<li></li>
<li> Stripe </li>
+50 -1
View File
@@ -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" });
});
});
+66 -13
View File
@@ -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<string>(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<Record<keyof CheckoutScriptInput, string>>;
export type CheckoutInputField = "coupon" | "country" | "currency" | "accessToken";
export type CheckoutValidationErrors = Partial<Record<CheckoutInputField, string>>;
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 = {
+14
View File
@@ -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; }