magpie-gemini-web 0.3.2 → 0.3.3

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.en.md CHANGED
@@ -43,7 +43,7 @@ magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
43
43
 
44
44
  ## Thinking effort
45
45
 
46
- You can switch a model between `low` and `high` effort in Magpie if it has a `-thinking` version. `low` uses the base model and `high` uses the `-thinking` model with extended thinking. After upgrading the plugin, run `magpie provider models gemini-web` to refresh the model list. Argon's medium tier will be added once upstream supports it ([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5)).
46
+ You can switch a model between `low` and `high` effort in Magpie if it has a `-thinking` version. `low` uses the base model and `high` uses the `-thinking` model with extended thinking, so the `-thinking` model isn't listed on its own. After upgrading the plugin, run `magpie provider models gemini-web` to refresh the model list. Argon's medium tier will be added once upstream supports it ([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5)).
47
47
 
48
48
  ## Security and data
49
49
 
@@ -61,6 +61,15 @@ Magpie's usage cards show each Google account's **5-hour** and **weekly** usage
61
61
 
62
62
  Note that the backend picks the Google account for each request from its pool in turn: how Magpie spreads requests over the cards doesn't decide which account serves them. Only the one-click local backend has usage; a backend you connect to yourself keeps its cookies out of the plugin's reach.
63
63
 
64
+ To send Google fewer requests, usage isn't read on every refresh. An account is read again once the backend has used it or one of its windows has reset, and never twice within 10 minutes. An idle account is read at most every 6 hours, which also picks up what you used in the Gemini web app.
65
+
66
+ Usage and emails are read the way the backend reaches Google for that account. When that way isn't certain, nothing is read and the card says why:
67
+
68
+ - the backend has a proxy pool, but the account isn't bound to a proxy yet (the backend binds it on its first request);
69
+ - the account's proxy is disabled, or is a SOCKS proxy.
70
+
71
+ So with a proxy pool, an account just added by one-click setup has no email yet. Send one request with it, sign in to it again with **「添加本地后端里已有的 Google 账号」**, then remove the sign-in without an email.
72
+
64
73
  ## Limitations
65
74
 
66
75
  - Upstream emulates tool calls with prompts, so they aren't always reliable. Flash models often don't call tools.
@@ -68,7 +77,7 @@ Note that the backend picks the Google account for each request from its pool in
68
77
 
69
78
  ## Argon patched build (optional)
70
79
 
71
- The official backend already supports `gemini-4-argon`, which needs a paid or Ultra account. This repo's patched build also verifies that responses really come from Argon, so a silent downgrade is caught, and it lowers latency. Building it requires Go 1.26+:
80
+ The official backend already supports `gemini-4-argon`, which needs a paid or Ultra account. This repo's patched build also verifies that responses really come from Argon, so a silent downgrade is caught, and it lowers latency. It also makes the backend's requests look more like a browser's: on a direct connection every request carries the full set of Chrome headers (the official build's direct chat requests say `Go-http-client/2.0`), and refreshing the cookies to keep a session alive no longer fetches the `/app` page right away. Building it requires Go 1.26+:
72
81
 
73
82
  ```bash
74
83
  git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git && cd magpie-gemini-web
@@ -81,6 +90,15 @@ npm run backend:install:argon
81
90
 
82
91
  Restart Magpie when the install finishes, and the plugin starts the Argon build in the background. If an older backend is still running, stop it first with `kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)`. Run `npm run backend:install` to switch back to the official build.
83
92
 
93
+ When `patches/argon.patch` changes (after you pull a new version of this repo, say), apply it again to clean sources and rebuild. Once the old backend is stopped, the plugin starts the new one on the next request:
94
+
95
+ ```bash
96
+ git -C backend-source checkout -- . && git -C backend-source clean -fdq
97
+ git -C backend-source apply ../patches/argon.patch
98
+ npm run backend:build:argon && npm run backend:install:argon
99
+ kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)
100
+ ```
101
+
84
102
  ## License
85
103
 
86
104
  MIT. The upstream gemini-web2api-go project is also MIT (see `LICENSE-UPSTREAM`).
package/README.md CHANGED
@@ -43,7 +43,7 @@ magpie plugin options magpie-gemini-web '{"allowTools":true,"allowImages":true}'
43
43
 
44
44
  ## 思考档位
45
45
 
46
- 有 `-thinking` 版本的模型可以在 Magpie 里切换 `low` / `high`:`low` 用普通模型,`high` 用 `-thinking` 模型(扩展思考)。升级插件后执行 `magpie provider models gemini-web` 刷新模型列表。Argon 的「中」档等上游支持([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5))。
46
+ 有 `-thinking` 版本的模型可以在 Magpie 里切换 `low` / `high`:`low` 用普通模型,`high` 用 `-thinking` 模型(扩展思考)。`-thinking` 模型因此不再单独列出。升级插件后执行 `magpie provider models gemini-web` 刷新模型列表。Argon 的「中」档等上游支持([#5](https://github.com/BUKOWSKIREAL/magpie-gemini-web/issues/5))。
47
47
 
48
48
  ## 安全与数据
49
49
 
@@ -61,6 +61,15 @@ Magpie 的额度卡片会显示 Google 账号的 **5 小时** 和 **每周** 用
61
61
 
62
62
  注意:请求最终由后端在账号池里轮流挑选 Google 账号,Magpie 在几张卡片之间怎么分配请求,不决定实际用的是哪个账号。只有一键配置的本地后端能显示用量;「连接已有后端」模式下读不到账号的 Cookie。
63
63
 
64
+ 为了少向 Google 发请求,用量不会每次刷新都去查:账号经后端用过、或者某个窗口到了重置时间,才重新读取,两次至少间隔 10 分钟;账号闲着时最多每 6 小时读一次(顺带反映你在 Gemini 网页里的用量)。
65
+
66
+ 读取用量和邮箱时,走的是后端访问 Google 的同一个出口。出口不确定时不读取,卡片上会写明原因:
67
+
68
+ - 配了代理池,但账号还没绑定代理(后端在它第一次请求时绑定);
69
+ - 账号绑定的代理已停用,或者是 SOCKS 代理。
70
+
71
+ 所以配了代理池时,一键配置刚添加的账号暂时没有邮箱。先用它发一次请求,再用「添加本地后端里已有的 Google 账号」重新登录它,然后删掉没有邮箱的那个登录。
72
+
64
73
  ## 局限
65
74
 
66
75
  - 工具调用由上游用提示词模拟,不一定可靠,Flash 系列经常不调用工具。
@@ -68,7 +77,7 @@ Magpie 的额度卡片会显示 Google 账号的 **5 小时** 和 **每周** 用
68
77
 
69
78
  ## Argon 增强版(可选)
70
79
 
71
- 官方后端已支持 `gemini-4-argon`(需付费 / Ultra 账号)。本仓库的补丁版额外校验返回的确实是 Argon(防止被静默降级),并降低延迟。需要 Go 1.26+:
80
+ 官方后端已支持 `gemini-4-argon`(需付费 / Ultra 账号)。本仓库的补丁版额外校验返回的确实是 Argon(防止被静默降级),并降低延迟。它也让后端的请求更像浏览器:直连时每个请求都带齐 Chrome 请求头(官方版直连时,对话请求的 UA 是 `Go-http-client/2.0`),保活刷新 Cookie 后也不再立即重新抓取 `/app` 页面。需要 Go 1.26+:
72
81
 
73
82
  ```bash
74
83
  git clone https://github.com/BUKOWSKIREAL/magpie-gemini-web.git && cd magpie-gemini-web
@@ -81,6 +90,19 @@ npm run backend:install:argon
81
90
 
82
91
  安装后重启 Magpie 即可,插件会在后台启动 Argon 版(如有旧后端在运行,先 `kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)`)。运行 `npm run backend:install` 可切回官方版。
83
92
 
93
+ `patches/argon.patch` 更新后(比如拉取了本仓库的新版本),需要在干净的源码上重新应用补丁并重新构建。停掉旧后端后,插件会在下一个请求时启动新版:
94
+
95
+ ```bash
96
+ git -C backend-source checkout -- . && git -C backend-source clean -fdq
97
+ git -C backend-source apply ../patches/argon.patch
98
+ npm run backend:build:argon && npm run backend:install:argon
99
+ kill $(cat ~/.local/share/magpie-gemini-web/backend.pid)
100
+ ```
101
+
102
+ ## 友链
103
+
104
+ [LINUX DO](https://linux.do/) — 真诚、友善、团结、专业的技术社区。
105
+
84
106
  ## 许可证
85
107
 
86
108
  MIT。上游 gemini-web2api-go 同为 MIT(见 `LICENSE-UPSTREAM`)。
package/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { normalizeBaseURL, settings, credentials, listModels, forward, UNREACHABLE_HEADER } from './lib/backend.mjs';
2
2
  import { ensureBackend, localBackend } from './lib/native.mjs';
3
3
  import { parseCookieInput, importCookie } from './lib/setup.mjs';
4
- import { usage, poolChoices, accountEmail } from './lib/usage.mjs';
4
+ import { usage, poolChoices, poolAccounts, accountEmail } from './lib/usage.mjs';
5
5
 
6
6
  const ID = 'gemini-web';
7
7
  const GEMINI_URL = 'https://gemini.google.com/app';
@@ -22,7 +22,9 @@ const COOKIE_STEPS = [
22
22
  // signInFor is the Magpie sign-in for one account of the local backend's pool: the backend's
23
23
  // key, with the account's id for its usage card and its Google email, which names the card and
24
24
  // keeps each Google account a sign-in of its own (Magpie takes two sign-ins with one email for
25
- // one account, and two with neither for one when their key is the same).
25
+ // one account, and two with neither for one when their key is the same). The account is the
26
+ // pool's own entry (see poolAccounts), which says the way out to read the email by; without
27
+ // one the email isn't read.
26
28
  async function signInFor(local, account) {
27
29
  const email = account.email || await accountEmail(account).catch(() => undefined);
28
30
  return {
@@ -96,7 +98,8 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
96
98
  } catch (err) {
97
99
  return { type: 'failed', error: err.message };
98
100
  }
99
- return signInFor(local, { id: added.id, cookie });
101
+ const account = (await poolAccounts().catch(() => [])).find(a => a.id === added.id);
102
+ return signInFor(local, account || { id: added.id });
100
103
  }
101
104
  };
102
105
  }
@@ -182,8 +185,8 @@ export const GeminiWebPlugin = async (_input, options = {}) => {
182
185
  if (!managedBackend(c.baseURL)) {
183
186
  return { error: '只能读取插件自己管理的本地后端的额度(连接已有后端时读不到)', windows: [], signIn: 'kept' };
184
187
  }
185
- const account = auth.metadata?.account;
186
- return { ...(await usage(Number.isInteger(account) ? { account } : {})), signIn: 'kept' };
188
+ const { account, email } = auth.metadata || {};
189
+ return { ...(await usage(Number.isInteger(account) ? { account, email } : {})), signIn: 'kept' };
187
190
  }
188
191
  },
189
192
  provider: {
package/lib/backend.mjs CHANGED
@@ -7,8 +7,9 @@ const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
7
7
  const NEWLINE_CHARS = /[\r\n]/;
8
8
 
9
9
  // The backend has no effort parameter: extended thinking is a separate "<id>-thinking" model.
10
- // A base model with such a twin offers low (itself) and high (the twin). Magpie maps any other
11
- // level to the nearest declared one before it reaches us; direct callers get the same rule.
10
+ // A base model with such a twin offers low (itself) and high (the twin), and the twin isn't
11
+ // listed on its own; a request that names it still goes through. Magpie maps any other level to
12
+ // the nearest declared one before it reaches us; direct callers get the same rule.
12
13
  const THINKING_SUFFIX = '-thinking';
13
14
  const LOW_EFFORTS = new Set(['none', 'minimal', 'low']);
14
15
  const MODEL_CACHE_MS = 10 * 60 * 1000;
@@ -285,6 +286,7 @@ export async function listModels(c, opts) {
285
286
 
286
287
  const result = Object.create(null);
287
288
  for (const id of validIds) {
289
+ if (id.endsWith(THINKING_SUFFIX) && ids.has(id.slice(0, -THINKING_SUFFIX.length))) continue;
288
290
  const hasTwin = !id.endsWith(THINKING_SUFFIX) && ids.has(id + THINKING_SUFFIX);
289
291
  result[id] = {
290
292
  id,
package/lib/usage.mjs CHANGED
@@ -23,7 +23,26 @@ const USAGE_VARIABLES = {
23
23
  }]
24
24
  }
25
25
  };
26
- const USER_AGENT = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36';
26
+ // The browser the backend says it is when it reaches Google (gemini-web2api-go v4.22.0:
27
+ // ChromeUA and applyChromeHeaders in client.go), so these reads come from the same client as
28
+ // its own requests on the account. The TLS handshake is still Bun's, as the backend's own is
29
+ // Go's when it goes through a proxy.
30
+ const CLIENT_HEADERS = {
31
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36',
32
+ 'Accept-Language': 'en-US,en;q=0.9',
33
+ 'Sec-CH-UA': '"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"',
34
+ 'Sec-CH-UA-Mobile': '?0',
35
+ 'Sec-CH-UA-Platform': '"Windows"'
36
+ };
37
+
38
+ // When an account's pools are read again. They move only while the account is used, which the
39
+ // backend records (usedAt, see poolAccounts), or when a window starts over; use from the Gemini
40
+ // app itself is caught by USAGE_MAX_AGE_MS. No two reads of an account are closer than
41
+ // USAGE_MIN_GAP_MS: Magpie set to read allowances automatically asks every few minutes, far
42
+ // more often than a person opens the Usage page. A failed read stands that long too, so a
43
+ // cookie Google turned away isn't asked about again on every refresh.
44
+ export const USAGE_MIN_GAP_MS = 10 * 60 * 1000;
45
+ export const USAGE_MAX_AGE_MS = 6 * 3600 * 1000;
27
46
 
28
47
  // The pools the Usage page shows, under the names Magpie gives the same windows elsewhere.
29
48
  const POOLS = [
@@ -45,12 +64,18 @@ async function openReadOnly(file) {
45
64
  }
46
65
 
47
66
  // poolAccounts lists the enabled accounts in the backend's pool, each with the proxy it is
48
- // bound to, if any.
67
+ // bound to, if any, and whether that proxy is enabled (proxyEnabled), whether the pool has
68
+ // proxies at all (pooled: disabled ones count, as they do for the backend), and when the
69
+ // backend last used it (usedAt, Unix seconds): the later of when it last took the account for
70
+ // a request and when it last finished one.
49
71
  export async function poolAccounts(dataDir = getDataDir()) {
50
72
  const db = await openReadOnly(path.join(dataDir, 'gemini.db'));
51
73
  try {
52
- return db.all(`SELECT a.id, a.label, a.cookie, p.url AS proxy
53
- FROM accounts a LEFT JOIN proxies p ON p.id = a.proxy_id AND p.enabled = 1
74
+ return db.all(`SELECT a.id, a.label, a.cookie, p.url AS proxy, p.enabled AS proxyEnabled,
75
+ EXISTS (SELECT 1 FROM proxies) AS pooled,
76
+ MAX(a.last_used_at, COALESCE((SELECT MAX(r.ts) FROM requests r
77
+ WHERE r.ts >= a.last_used_at AND r.account_id = a.id), 0)) AS usedAt
78
+ FROM accounts a LEFT JOIN proxies p ON p.id = a.proxy_id
54
79
  WHERE a.status = 'enabled' ORDER BY a.id`);
55
80
  } finally {
56
81
  db.close();
@@ -71,17 +96,49 @@ export function parseUsage(text) {
71
96
  throw new Error('Gemini 的回复里没有用量数据');
72
97
  }
73
98
 
74
- // geminiRequest is one signed-in request to gemini.google.com as the account, through the
75
- // proxy the backend bound it to. A redirect is Google sending a dead cookie to sign-in.
99
+ // The variables that send Bun's fetch, or Node's with NODE_USE_ENV_PROXY, through a proxy
100
+ // even when the request names none.
101
+ const PROXY_ENV = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy', 'ALL_PROXY', 'all_proxy'];
102
+
103
+ // egress is the proxy a read for the account goes through: the way the backend reaches Google
104
+ // for it, since a read from anywhere else would show Google the session at a second address.
105
+ // A read is made only when that way is certain, as poolAccounts tells it:
106
+ // - a pool without proxies (none at all, not just none enabled): the backend goes direct,
107
+ // and so does the read, unless proxy variables would take it elsewhere;
108
+ // - an account bound to an enabled proxy: that proxy, which its requests and keepalive both
109
+ // take. Only Bun's fetch takes a proxy (a fetchFn passed in stands for it), and only an
110
+ // HTTP(S) one;
111
+ // - anything else is unknown: an account not bound yet (the backend binds one on its first
112
+ // request, and keeps it alive direct until then), one bound to a disabled proxy (its
113
+ // requests take another, its keepalive still that one), or one poolAccounts didn't list.
114
+ function egress(account, options) {
115
+ if (account.pooled == null) throw new Error('不知道后端给这个账号走哪个出口,先不读用量');
116
+ if (!account.pooled) {
117
+ const env = options.env ?? process.env;
118
+ const set = PROXY_ENV.find(k => env[k]);
119
+ if (set) throw new Error(`后端直连 Google,但这里设置了 ${set},读取会改走代理,先不读用量`);
120
+ return undefined;
121
+ }
122
+ if (!account.proxy) throw new Error('这个账号还没绑定代理池里的出口(第一次请求后后端会自动绑定),先不读用量,免得从别的 IP 访问 Google');
123
+ if (!account.proxyEnabled) throw new Error('这个账号绑定的代理已停用,后端的请求和保活会走不同出口,先不读用量');
124
+ if (!/^https?:\/\//i.test(account.proxy) || !(options.fetchFn || globalThis.Bun)) {
125
+ throw new Error('这个账号绑定的代理插件用不了(只支持 HTTP / HTTPS 代理),不读用量,免得从别的 IP 访问 Google');
126
+ }
127
+ return account.proxy;
128
+ }
129
+
130
+ // geminiRequest is one signed-in request to gemini.google.com as the account, the way out the
131
+ // backend takes (egress). It always names its proxy, none included: Magpie's host gives a fetch
132
+ // that names none Magpie's own proxy, which the backend doesn't take. A redirect is Google
133
+ // sending a dead cookie to sign-in.
76
134
  async function geminiRequest(url, account, headers, options) {
77
135
  const fetchFn = options.fetchFn || globalThis.fetch;
78
136
  const init = {
79
- headers: { ...headers, Cookie: account.cookie, 'User-Agent': USER_AGENT },
137
+ headers: { ...CLIENT_HEADERS, ...headers, Cookie: account.cookie },
80
138
  redirect: 'manual',
139
+ proxy: egress(account, options),
81
140
  signal: AbortSignal.timeout(options.timeoutMs ?? 15000)
82
141
  };
83
- // Bun's fetch takes an HTTP proxy; an account bound to one reaches Google only through it.
84
- if (/^https?:\/\//i.test(account.proxy || '')) init.proxy = account.proxy;
85
142
  const res = await fetchFn(url, init);
86
143
  if (res.status >= 300 && res.status < 400 || res.status === 401 || res.status === 403) {
87
144
  throw new Error('Cookie 已失效,请重新走一遍一键配置');
@@ -96,21 +153,61 @@ export async function accountUsage(account, options = {}) {
96
153
  return parseUsage(await geminiRequest(url, account, {
97
154
  Accept: 'application/json',
98
155
  Origin: 'https://gemini.google.com',
99
- Referer: 'https://gemini.google.com/usage'
156
+ Referer: 'https://gemini.google.com/usage',
157
+ 'Sec-Fetch-Dest': 'empty',
158
+ 'Sec-Fetch-Mode': 'cors',
159
+ 'Sec-Fetch-Site': 'same-origin'
100
160
  }, options));
101
161
  }
102
162
 
163
+ // The last read of each account: id -> { at, read, pools }, pools once it has succeeded.
164
+ const reads = new Map();
165
+
166
+ // due says whether the account's last read is to be made again (see USAGE_MIN_GAP_MS).
167
+ function due(last, account, options) {
168
+ const now = Date.now();
169
+ const age = now - last.at;
170
+ if (age < (options.minGapMs ?? USAGE_MIN_GAP_MS)) return false;
171
+ if (!last.pools || age >= (options.maxAgeMs ?? USAGE_MAX_AGE_MS)) return true;
172
+ if ((account.usedAt ?? 0) >= Math.floor(last.at / 1000)) return true;
173
+ return last.pools.some(p => {
174
+ const at = Date.parse(p?.gxu?.windowRefreshesAt ?? '');
175
+ return at > last.at && at <= now;
176
+ });
177
+ }
178
+
179
+ function readUsage(account, options) {
180
+ const last = reads.get(account.id);
181
+ if (last && !due(last, account, options)) return last.read;
182
+ const entry = { at: Date.now() };
183
+ entry.read = accountUsage(account, options).then(u => ((entry.pools = u.pools), u));
184
+ reads.set(account.id, entry);
185
+ return entry.read;
186
+ }
187
+
103
188
  // The Google account's email, from the signed-in app page's bootstrap data (WIZ_global_data's
104
- // oPEP7c), kept per backend account: the page is large and the email doesn't change.
105
- const emails = new Map();
189
+ // oPEP7c), kept per backend account: the page is large and the email doesn't change. A page
190
+ // that didn't give it is asked for again no sooner than USAGE_MAX_AGE_MS: the email only names
191
+ // a card. An account with no certain way out (egress) isn't asked yet, and that isn't a try.
192
+ const emails = new Map(); // id -> { email }, or { at } since the last try that got none
106
193
  const EMAIL_FIELD = /"oPEP7c":"([^"@\s]+@[^"\s]+)"/;
107
194
 
108
195
  export async function accountEmail(account, options = {}) {
109
196
  const known = emails.get(account.id);
110
- if (known) return known;
111
- const page = await geminiRequest('https://gemini.google.com/app', account, { Accept: 'text/html' }, options);
197
+ if (known?.email) return known.email;
198
+ if (known && Date.now() - known.at < (options.maxAgeMs ?? USAGE_MAX_AGE_MS)) return undefined;
199
+ egress(account, options);
200
+ emails.set(account.id, { at: Date.now() });
201
+ const page = await geminiRequest('https://gemini.google.com/app', account, {
202
+ Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
203
+ 'Sec-Fetch-Dest': 'document',
204
+ 'Sec-Fetch-Mode': 'navigate',
205
+ 'Sec-Fetch-Site': 'none',
206
+ 'Sec-Fetch-User': '?1',
207
+ 'Upgrade-Insecure-Requests': '1'
208
+ }, options);
112
209
  const email = EMAIL_FIELD.exec(page)?.[1];
113
- if (email) emails.set(account.id, email);
210
+ if (email) emails.set(account.id, { email });
114
211
  return email;
115
212
  }
116
213
 
@@ -153,6 +250,7 @@ export function poolWindows(read) {
153
250
  // usage is the Magpie usage card: one account's 5-hour and weekly windows, as
154
251
  // gemini.google.com/usage shows them, when the sign-in names its backend account; else the
155
252
  // whole pool's, as one card. Accounts go by their Google emails (#id until that is known).
253
+ // An account is read from Google again only when due (see USAGE_MIN_GAP_MS).
156
254
  // Magpie hides a card's windows behind an error, so a pool account that can't be read only
157
255
  // says so in the tooltip while the others still show.
158
256
  export async function usage(options = {}) {
@@ -165,11 +263,15 @@ export async function usage(options = {}) {
165
263
  if (options.account !== undefined) {
166
264
  accounts = accounts.filter(a => a.id === options.account);
167
265
  if (!accounts.length) return { error: `本地后端里已经没有这个账号(#${options.account}),可能被停用或删除了`, windows: [] };
266
+ // The sign-in already names its account's email: no app page to fetch for it.
267
+ if (typeof options.email === 'string' && options.email && !emails.get(options.account)?.email) {
268
+ emails.set(options.account, { email: options.email });
269
+ }
168
270
  }
169
271
  if (!accounts.length) return { error: '本地后端里没有启用的 Google 账号', windows: [] };
170
272
 
171
273
  const read = await Promise.all(accounts.map(async a => {
172
- const [u, email] = await Promise.allSettled([accountUsage(a, options), accountEmail(a, options)]);
274
+ const [u, email] = await Promise.allSettled([readUsage(a, options), accountEmail(a, options)]);
173
275
  const who = (email.status === 'fulfilled' && email.value) || `#${a.id}`;
174
276
  return u.status === 'fulfilled' ? { who, windows: windowsOf(u.value.pools) } : { who, error: u.reason?.message || String(u.reason) };
175
277
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magpie-gemini-web",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Gemini Web provider for Magpie, backed by gemini-web2api-go",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",
@@ -699,6 +699,53 @@ index 0000000..e5f1b6f
699
699
  + }
700
700
  + t.Fatal("没有记录这次请求")
701
701
  +}
702
+ diff --git a/internal/app/client.go b/internal/app/client.go
703
+ index 1750391..e613956 100644
704
+ --- a/internal/app/client.go
705
+ +++ b/internal/app/client.go
706
+ @@ -154,17 +154,32 @@ func makeSAPISIDHash(sapisid string) string {
707
+ return fmt.Sprintf("SAPISIDHASH %d_%s", ts, hex.EncodeToString(h[:]))
708
+ }
709
+
710
+ -// ChromeUA 是给 stdlib 走代理时用的 Chrome 146 真实 UA 模板。
711
+ +// ChromeUA 是 Chrome 146 的真实 UA 模板。
712
+ const ChromeUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
713
+
714
+ -// applyChromeHeaders 给 stdlib 请求填上模拟 Chrome 146 的应用层 header。
715
+ -// utls 那层做 TLS/HTTP2 指纹我们做不到(走代理),但 header 一定要齐。
716
+ +// chromeHeaders 是模拟 Chrome 146 的应用层 header,两条传输路径都要带齐:
717
+ +// 走代理时 utls 那层的 TLS/HTTP2 指纹我们做不到(stdlib),但 header 一定要齐;
718
+ +// 直连时 tls-client 只管指纹、不填 header,什么都不带的话 UA 是 Go-http-client/2.0,
719
+ +// 跟 Chrome 的握手对不上,同一个账号也会因为走没走代理而呈现两种客户端。
720
+ +var chromeHeaders = [][2]string{
721
+ + {"User-Agent", ChromeUA},
722
+ + {"Sec-CH-UA", `"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"`},
723
+ + {"Sec-CH-UA-Mobile", "?0"},
724
+ + {"Sec-CH-UA-Platform", `"Windows"`},
725
+ + {"Sec-Fetch-Dest", "empty"},
726
+ + {"Sec-Fetch-Mode", "cors"},
727
+ + {"Sec-Fetch-Site", "same-origin"},
728
+ +}
729
+ +
730
+ +// setChromeHeaders 把 chromeHeaders 填进 net/http 或 fhttp 的 Header(两者都有 Set)。
731
+ +// 调用方之后再写自己的 header,同名的以调用方为准。
732
+ +func setChromeHeaders(h interface{ Set(key, value string) }) {
733
+ + for _, kv := range chromeHeaders {
734
+ + h.Set(kv[0], kv[1])
735
+ + }
736
+ +}
737
+ +
738
+ +// applyChromeHeaders 给 stdlib 请求填上 chromeHeaders。
739
+ func applyChromeHeaders(req *http.Request) {
740
+ - req.Header.Set("User-Agent", ChromeUA)
741
+ - req.Header.Set("Sec-CH-UA", `"Chromium";v="146", "Google Chrome";v="146", "Not?A_Brand";v="24"`)
742
+ - req.Header.Set("Sec-CH-UA-Mobile", "?0")
743
+ - req.Header.Set("Sec-CH-UA-Platform", `"Windows"`)
744
+ - req.Header.Set("Sec-Fetch-Dest", "empty")
745
+ - req.Header.Set("Sec-Fetch-Mode", "cors")
746
+ - req.Header.Set("Sec-Fetch-Site", "same-origin")
747
+ + setChromeHeaders(req.Header)
748
+ }
702
749
  diff --git a/internal/app/context_file.go b/internal/app/context_file.go
703
750
  index bfc577b..cb60661 100644
704
751
  --- a/internal/app/context_file.go
@@ -779,7 +826,7 @@ index bfc577b..cb60661 100644
779
826
  + return promptParts{body: parts}.join()
780
827
  }
781
828
  diff --git a/internal/app/conversation.go b/internal/app/conversation.go
782
- index aef12eb..cbf1371 100644
829
+ index aef12eb..8ba963f 100644
783
830
  --- a/internal/app/conversation.go
784
831
  +++ b/internal/app/conversation.go
785
832
  @@ -1,6 +1,7 @@
@@ -790,7 +837,23 @@ index aef12eb..cbf1371 100644
790
837
  "crypto/sha256"
791
838
  "encoding/hex"
792
839
  "encoding/json"
793
- @@ -244,8 +245,11 @@ const webUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
840
+ @@ -205,7 +206,7 @@ func getAnonSession(proxyURL string) (string, error) {
841
+ if err != nil {
842
+ return nil, nil, nil, err
843
+ }
844
+ - r.Header.Set("User-Agent", webUA)
845
+ + setChromeHeaders(r.Header)
846
+ resp, err := getTLSClient().Do(r)
847
+ if err != nil {
848
+ return nil, nil, nil, err
849
+ @@ -237,15 +238,14 @@ func getAnonSession(proxyURL string) (string, error) {
850
+ return strings.Join(pairs, "; "), nil
851
+ }
852
+
853
+ -// webUA 是匿名 GET /app 的 User-Agent(tls-client 路径要手动带)。
854
+ -const webUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
855
+ - "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
856
+ -
794
857
  // streamGenerateConv 发一路会话的一轮。turn==0 是首轮(建会话),>0 是续接(只发新消息)。
795
858
  // 整条会话绑定 conv.cookie / conv.proxyID,中途不换号不换出口。成功后回填 conv 的
796
859
  // cid/rid/rcid/tok26 和 turn+1,供下一轮续接。
@@ -803,7 +866,7 @@ index aef12eb..cbf1371 100644
803
866
  p, slotOK, slotErr := acquireSlot(conv.proxyID)
804
867
  if !slotOK {
805
868
  return &StreamResult{}, slotErr
806
- @@ -365,13 +369,19 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
869
+ @@ -365,13 +365,19 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
807
870
  t0 := time.Now()
808
871
  var lastErr error
809
872
  for attempt := 0; attempt < rtCfg().RetryAttempts; attempt++ {
@@ -824,7 +887,7 @@ index aef12eb..cbf1371 100644
824
887
  if err == nil && statusCode == 200 {
825
888
  lastErr = fmt.Errorf("续接无内容帧(会话可能已失效,raw %d 字节)", len(raw))
826
889
  } else if err != nil {
827
- @@ -386,7 +396,9 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
890
+ @@ -386,7 +392,9 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
828
891
  break
829
892
  }
830
893
  if attempt < rtCfg().RetryAttempts-1 {
@@ -835,7 +898,7 @@ index aef12eb..cbf1371 100644
835
898
  }
836
899
  continue
837
900
  }
838
- @@ -427,7 +439,7 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
901
+ @@ -427,7 +435,7 @@ func streamGenerateConv(prompt string, mc ModelConfig, conv *convState,
839
902
  // - 没命中 → 新建会话,首轮发全量拼接的历史(含 tools 指令,把当前上下文交给服务端)。
840
903
  //
841
904
  // 支持 tools(续接里也解析 ```tool_call``` 围栏);图片走原单轮路径(callGemini)。
@@ -844,7 +907,7 @@ index aef12eb..cbf1371 100644
844
907
  tools []map[string]interface{}, toolChoice interface{},
845
908
  onDelta, onReasoning func(string)) (string, []ToolCall, *StreamResult, error) {
846
909
  parentKey := convParentKey(messages)
847
- @@ -462,7 +474,7 @@ func callGeminiConv(messages []map[string]interface{}, mc ModelConfig,
910
+ @@ -462,7 +470,7 @@ func callGeminiConv(messages []map[string]interface{}, mc ModelConfig,
848
911
  logf("[conv] 续接命中 turn=%d,只发 %d 字节(历史留服务端)", conv.turn, len(prompt))
849
912
  }
850
913
 
@@ -854,7 +917,7 @@ index aef12eb..cbf1371 100644
854
917
  if !fresh {
855
918
  // 续接失败:这路会话作废,客户端重试时会当新会话全量重发。
856
919
  diff --git a/internal/app/gemini.go b/internal/app/gemini.go
857
- index 723725f..a1e5221 100644
920
+ index 723725f..150b022 100644
858
921
  --- a/internal/app/gemini.go
859
922
  +++ b/internal/app/gemini.go
860
923
  @@ -3,6 +3,7 @@ package app
@@ -1148,7 +1211,7 @@ index 723725f..a1e5221 100644
1148
1211
  if err != nil {
1149
1212
  return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
1150
1213
  }
1151
- @@ -844,7 +944,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1214
+ @@ -844,10 +944,11 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1152
1215
  }
1153
1216
 
1154
1217
  // 直连 → tls-client,保留 chrome146 TLS/HTTP2 真指纹
@@ -1157,7 +1220,11 @@ index 723725f..a1e5221 100644
1157
1220
  if err != nil {
1158
1221
  return 0, nil, 0, nil, err
1159
1222
  }
1160
- @@ -857,7 +957,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1223
+ + setChromeHeaders(req.Header)
1224
+ for k, v := range headers {
1225
+ req.Header.Set(k, v)
1226
+ }
1227
+ @@ -857,7 +958,7 @@ func doGeminiRequest(endpoint, body string, headers map[string]string, proxyURL
1161
1228
  return 0, nil, 0, nil, err
1162
1229
  }
1163
1230
  defer resp.Body.Close()
@@ -1166,6 +1233,14 @@ index 723725f..a1e5221 100644
1166
1233
  if err != nil {
1167
1234
  return resp.StatusCode, nil, ttfb, resp.Header.Values("Set-Cookie"), err
1168
1235
  }
1236
+ @@ -1159,6 +1260,7 @@ func probeGemini(prompt, proxyURL string) ProbeResult {
1237
+ res.Diagnostic = "构建请求失败: " + e.Error()
1238
+ return res
1239
+ }
1240
+ + setChromeHeaders(req.Header)
1241
+ for k, v := range headers {
1242
+ req.Header.Set(k, v)
1243
+ }
1169
1244
  diff --git a/internal/app/gemini_test.go b/internal/app/gemini_test.go
1170
1245
  index 00eb305..2b4c6ca 100644
1171
1246
  --- a/internal/app/gemini_test.go
@@ -1178,12 +1253,114 @@ index 00eb305..2b4c6ca 100644
1178
1253
  }
1179
1254
  for _, c := range cases {
1180
1255
  name, mc, err := resolveModel(c.name)
1256
+ diff --git a/internal/app/headers_test.go b/internal/app/headers_test.go
1257
+ new file mode 100644
1258
+ index 0000000..b80a9fa
1259
+ --- /dev/null
1260
+ +++ b/internal/app/headers_test.go
1261
+ @@ -0,0 +1,96 @@
1262
+ +package app
1263
+ +
1264
+ +import (
1265
+ + "context"
1266
+ + "net/http"
1267
+ + "net/http/httptest"
1268
+ + "testing"
1269
+ +
1270
+ + tls_client "github.com/bogdanfinn/tls-client"
1271
+ +)
1272
+ +
1273
+ +// 同一个账号的请求不能因为走没走代理而呈现两种客户端:两条路径都要带齐 chromeHeaders,
1274
+ +// 调用方自己的 header 照样生效。直连那条以前一个都不带,UA 是 Go-http-client/2.0。
1275
+ +//
1276
+ +// 直连走 tls-client,这里换成跳过证书校验的同款客户端,打本机的 HTTPS 测试服务器;
1277
+ +// 代理那条把 http:// 目标以绝对 URI 发给代理,测试服务器本身就能当代理(同 cancel_test)。
1278
+ +func TestDirectAndProxyPathsSendSameBrowserHeaders(t *testing.T) {
1279
+ + got := map[string]http.Header{}
1280
+ + record := func(name string) http.Handler {
1281
+ + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1282
+ + got[name] = r.Header.Clone()
1283
+ + w.WriteHeader(200)
1284
+ + })
1285
+ + }
1286
+ + direct := httptest.NewUnstartedServer(record("direct"))
1287
+ + direct.EnableHTTP2 = true
1288
+ + direct.StartTLS()
1289
+ + defer direct.Close()
1290
+ + proxy := httptest.NewServer(record("proxy"))
1291
+ + defer proxy.Close()
1292
+ +
1293
+ + key := rtCfg().Impersonate
1294
+ + insecure, err := tls_client.NewHttpClient(tls_client.NewNoopLogger(),
1295
+ + tls_client.WithClientProfile(resolveProfile(key)),
1296
+ + tls_client.WithInsecureSkipVerify(),
1297
+ + tls_client.WithNotFollowRedirects())
1298
+ + if err != nil {
1299
+ + t.Fatal(err)
1300
+ + }
1301
+ + clientCacheMu.Lock()
1302
+ + prev, had := clientCache[key]
1303
+ + clientCache[key] = insecure
1304
+ + clientCacheMu.Unlock()
1305
+ + t.Cleanup(func() {
1306
+ + clientCacheMu.Lock()
1307
+ + defer clientCacheMu.Unlock()
1308
+ + if had {
1309
+ + clientCache[key] = prev
1310
+ + } else {
1311
+ + delete(clientCache, key)
1312
+ + }
1313
+ + })
1314
+ +
1315
+ + own := map[string]string{"Accept-Language": "en-US,en;q=0.9", "X-Same-Domain": "1"}
1316
+ + paths := []struct {
1317
+ + name string
1318
+ + send func(endpoint, proxyURL string)
1319
+ + }{
1320
+ + {"StreamGenerate", func(endpoint, proxyURL string) {
1321
+ + doGeminiRequestStop(context.Background(), endpoint, "f.req=x", own, proxyURL, nil, nil)
1322
+ + }},
1323
+ + {"upload", func(endpoint, proxyURL string) {
1324
+ + uploadPostCtx(context.Background(), endpoint, own, []byte("x"), proxyURL)
1325
+ + }},
1326
+ + {"media", func(endpoint, proxyURL string) {
1327
+ + mediaGetOnce(endpoint, own, proxyURL)
1328
+ + }},
1329
+ + {"image", func(endpoint, proxyURL string) {
1330
+ + fetchImage(endpoint, proxyURL, 0)
1331
+ + }},
1332
+ + }
1333
+ + for _, p := range paths {
1334
+ + clear(got)
1335
+ + p.send(direct.URL+"/x", "")
1336
+ + p.send("http://upstream.invalid/x", proxy.URL)
1337
+ + d, pr := got["direct"], got["proxy"]
1338
+ + if d == nil || pr == nil {
1339
+ + t.Fatalf("%s: 没收到请求(direct=%v proxy=%v)", p.name, d != nil, pr != nil)
1340
+ + }
1341
+ + if ua := d.Get("User-Agent"); ua != ChromeUA {
1342
+ + t.Errorf("%s: 直连 UA = %q", p.name, ua)
1343
+ + }
1344
+ + for _, kv := range chromeHeaders {
1345
+ + if d.Get(kv[0]) != pr.Get(kv[0]) {
1346
+ + t.Errorf("%s: %s 直连 %q,代理 %q", p.name, kv[0], d.Get(kv[0]), pr.Get(kv[0]))
1347
+ + }
1348
+ + }
1349
+ + if p.name != "image" {
1350
+ + for k, v := range own {
1351
+ + if d.Get(k) != v || pr.Get(k) != v {
1352
+ + t.Errorf("%s: 调用方的 %s 没生效(直连 %q,代理 %q)", p.name, k, d.Get(k), pr.Get(k))
1353
+ + }
1354
+ + }
1355
+ + }
1356
+ + }
1357
+ +}
1181
1358
  diff --git a/internal/app/latency_test.go b/internal/app/latency_test.go
1182
1359
  new file mode 100644
1183
- index 0000000..9329309
1360
+ index 0000000..b30336f
1184
1361
  --- /dev/null
1185
1362
  +++ b/internal/app/latency_test.go
1186
- @@ -0,0 +1,299 @@
1363
+ @@ -0,0 +1,297 @@
1187
1364
  +package app
1188
1365
  +
1189
1366
  +import (
@@ -1406,17 +1583,15 @@ index 0000000..9329309
1406
1583
  + }
1407
1584
  +}
1408
1585
  +
1409
- +// 轮转后重取失败时必须作废缓存,否则失效的号会一直拿着旧 token 被当成可用。
1410
- +func TestRefreshXSRFFailureDropsCache(t *testing.T) {
1586
+ +// 后台重取失败时必须作废缓存,否则失效的号会一直拿着旧 token 被当成可用。
1587
+ +func TestRefreshAppTokensFailureDropsCache(t *testing.T) {
1411
1588
  + cookie := "SAPISID=sap2; __Secure-1PSID=psid2"
1412
1589
  + xsrfMu.Lock()
1413
1590
  + xsrfCache[cookieKey(cookie)] = xsrfEntry{token: "stale", fetched: time.Now()}
1414
1591
  + xsrfMu.Unlock()
1415
1592
  + t.Cleanup(func() { invalidateXSRF(cookie) })
1416
1593
  +
1417
- + if err := refreshXSRF(cookie, "http://127.0.0.1:1"); err == nil {
1418
- + t.Fatal("不可达出口应报错")
1419
- + }
1594
+ + refreshAppTokens(cookieKey(cookie), cookie, "http://127.0.0.1:1")
1420
1595
  + xsrfMu.Lock()
1421
1596
  + _, ok := xsrfCache[cookieKey(cookie)]
1422
1597
  + xsrfMu.Unlock()
@@ -1483,6 +1658,18 @@ index 0000000..9329309
1483
1658
  + t.Fatalf("非流式: stop=%v reads=%d tracker=%q", lh.stop.Load(), src.reads, lh.tracker.emitted)
1484
1659
  + }
1485
1660
  +}
1661
+ diff --git a/internal/app/media.go b/internal/app/media.go
1662
+ index 9844778..03610f9 100644
1663
+ --- a/internal/app/media.go
1664
+ +++ b/internal/app/media.go
1665
+ @@ -505,6 +505,7 @@ func mediaGetOnce(rawURL string, headers map[string]string, proxyURL string) (in
1666
+ if err != nil {
1667
+ return 0, nil, nil, err
1668
+ }
1669
+ + setChromeHeaders(req.Header)
1670
+ for k, v := range headers {
1671
+ req.Header.Set(k, v)
1672
+ }
1486
1673
  diff --git a/internal/app/messages.go b/internal/app/messages.go
1487
1674
  index 376171f..87b91fe 100644
1488
1675
  --- a/internal/app/messages.go
@@ -1680,26 +1867,6 @@ index 376171f..87b91fe 100644
1680
1867
  // parseToolChoice 解析 OpenAI 的 tool_choice。
1681
1868
  // 返回 (mode, forcedName):mode ∈ {"auto","none","required"};
1682
1869
  // forcedName 非空表示客户端点名了某个函数。
1683
- diff --git a/internal/app/rotate.go b/internal/app/rotate.go
1684
- index e082b15..908a052 100644
1685
- --- a/internal/app/rotate.go
1686
- +++ b/internal/app/rotate.go
1687
- @@ -95,7 +95,14 @@ func rotateAccount(a CookieAccount) (time.Duration, []string, error) {
1688
- if cookie != a.Cookie {
1689
- old := a.Cookie
1690
- updateAccountCookie(a.ID, cookie)
1691
- - invalidateXSRF(old)
1692
- + // 轮转本身不在请求路径上,顺手用新 cookie 重取 token:既验证轮转结果,
1693
- + // 又让下一个请求直接命中缓存。失败时缓存已作废,下个请求同步重取。
1694
- + if err := refreshXSRF(cookie, proxyURL); err != nil {
1695
- + logf("[rotate] 账号 #%d 轮转后取 XSRF 失败: %v", a.ID, err)
1696
- + }
1697
- + if cookieKey(old) != cookieKey(cookie) {
1698
- + invalidateXSRF(old) // 身份项都变了(理论上不会):旧键别留着
1699
- + }
1700
- }
1701
- if len(names) > 0 {
1702
- logf("[rotate] 账号 #%d 刷新了 %s", a.ID, strings.Join(names, ", "))
1703
1870
  diff --git a/internal/app/server.go b/internal/app/server.go
1704
1871
  index c3a7df1..eb02909 100644
1705
1872
  --- a/internal/app/server.go
@@ -1808,7 +1975,7 @@ index c3a7df1..eb02909 100644
1808
1975
  if stream {
1809
1976
  writeEvent("response.failed", map[string]interface{}{
1810
1977
  diff --git a/internal/app/upload.go b/internal/app/upload.go
1811
- index 7747a72..09e8a04 100644
1978
+ index 7747a72..2753f93 100644
1812
1979
  --- a/internal/app/upload.go
1813
1980
  +++ b/internal/app/upload.go
1814
1981
  @@ -2,6 +2,7 @@ package app
@@ -1865,7 +2032,7 @@ index 7747a72..09e8a04 100644
1865
2032
  if err != nil {
1866
2033
  return 0, nil, nil, err
1867
2034
  }
1868
- @@ -131,7 +142,7 @@ func uploadPost(url string, headers map[string]string, body []byte, proxyURL str
2035
+ @@ -131,10 +142,11 @@ func uploadPost(url string, headers map[string]string, body []byte, proxyURL str
1869
2036
  }
1870
2037
  return resp.StatusCode, h, b, err
1871
2038
  }
@@ -1874,6 +2041,10 @@ index 7747a72..09e8a04 100644
1874
2041
  if err != nil {
1875
2042
  return 0, nil, nil, err
1876
2043
  }
2044
+ + setChromeHeaders(req.Header)
2045
+ for k, v := range headers {
2046
+ req.Header.Set(k, v)
2047
+ }
1877
2048
  diff --git a/internal/app/videos.go b/internal/app/videos.go
1878
2049
  index 390b580..0365a78 100644
1879
2050
  --- a/internal/app/videos.go
@@ -1895,8 +2066,20 @@ index 390b580..0365a78 100644
1895
2066
  if err != nil {
1896
2067
  recordRequest("videos", j.Model, j.Prompt, "", res, 502, err.Error(), false)
1897
2068
  finishVideoJob(j, nil, "", err.Error())
2069
+ diff --git a/internal/app/vision.go b/internal/app/vision.go
2070
+ index b0c9724..b1ca7cb 100644
2071
+ --- a/internal/app/vision.go
2072
+ +++ b/internal/app/vision.go
2073
+ @@ -160,6 +160,7 @@ func fetchImage(src, proxyURL string, idx int) (pendingUpload, error) {
2074
+ if err != nil {
2075
+ return pendingUpload{}, err
2076
+ }
2077
+ + setChromeHeaders(req.Header)
2078
+ resp, err := getTLSClient().Do(req)
2079
+ if err != nil {
2080
+ return pendingUpload{}, fmt.Errorf("image %d: fetch failed: %w", idx, err)
1898
2081
  diff --git a/internal/app/xsrf.go b/internal/app/xsrf.go
1899
- index c43b104..55634b6 100644
2082
+ index c43b104..d3d7145 100644
1900
2083
  --- a/internal/app/xsrf.go
1901
2084
  +++ b/internal/app/xsrf.go
1902
2085
  @@ -27,15 +27,23 @@ var (
@@ -1961,7 +2144,7 @@ index c43b104..55634b6 100644
1961
2144
 
1962
2145
  e, err := fetchAppTokens(cookie, proxyURL)
1963
2146
  if err != nil {
1964
- @@ -84,16 +99,59 @@ func getXSRF(cookie, proxyURL string) (string, error) {
2147
+ @@ -84,16 +99,48 @@ func getXSRF(cookie, proxyURL string) (string, error) {
1965
2148
  return e.token, nil
1966
2149
  }
1967
2150
 
@@ -1988,17 +2171,6 @@ index c43b104..55634b6 100644
1988
2171
  + }
1989
2172
  +}
1990
2173
  +
1991
- +// refreshXSRF 立即重抓页面并写回缓存,失败则作废缓存。
1992
- +//
1993
- +// 给本来就不在请求关键路径上的调用方用(cookie 轮转):轮转后直接作废的话,
1994
- +// 缓存按账号身份取键,下一个请求就得同步抓一次页面(实测约 1.2 秒)。
1995
- +func refreshXSRF(cookie, proxyURL string) error {
1996
- + if cookie == "" {
1997
- + return nil
1998
- + }
1999
- + return storeAppTokens(cookieKey(cookie), cookie, proxyURL)
2000
- +}
2001
- +
2002
2174
  +func storeAppTokens(key, cookie, proxyURL string) error {
2003
2175
  + e, err := fetchAppTokens(cookie, proxyURL)
2004
2176
  + xsrfMu.Lock()
@@ -2025,3 +2197,11 @@ index c43b104..55634b6 100644
2025
2197
 
2026
2198
  e, err := fetchAppTokens(cookie, proxyURL)
2027
2199
  if err != nil {
2200
+ @@ -146,6 +193,7 @@ func fetchAppPage(cookie, proxyURL string) ([]byte, error) {
2201
+ if err != nil {
2202
+ return nil, err
2203
+ }
2204
+ + setChromeHeaders(req.Header)
2205
+ for k, v := range headers {
2206
+ req.Header.Set(k, v)
2207
+ }
@@ -41,7 +41,7 @@ async function main() {
41
41
  const sha256 = crypto.createHash('sha256').update(bytes).digest('hex');
42
42
 
43
43
  const manifest = {
44
- version: '4.22.0-argon.1',
44
+ version: '4.22.0-argon.2',
45
45
  sourceCommit: pinned,
46
46
  sha256,
47
47
  platform: 'darwin',