magpie-plugin-msmoli 0.2.0 → 0.3.0
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 +550 -372
- package/bin/sync.mjs +1569 -148
- package/index.mjs +2456 -272
- package/package.json +3 -4
- package/README.zh-CN.md +0 -358
package/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// ModelScope
|
|
2
|
-
// modelscope.cn
|
|
1
|
+
// ModelScope credits check-in for magpie — one provider that serves both
|
|
2
|
+
// modelscope.cn and modelscope.ai, each account on the site its token is from.
|
|
3
3
|
//
|
|
4
4
|
// magpie calls auth.checkin once a Beijing day per signed-in account while the
|
|
5
5
|
// provider's check-in switch is on, and again later that day after a failure.
|
|
@@ -8,48 +8,71 @@
|
|
|
8
8
|
// How the claim works: ModelScope's OpenAPI has no check-in endpoint. What it
|
|
9
9
|
// has is a side effect — GET /api/v1/users/login/info counts as a website
|
|
10
10
|
// sign-in, and the server grants that day's credits with it (200 for the
|
|
11
|
-
// "
|
|
11
|
+
// "register-and-sign-in" daily grant, 50 for a bound Aliyun account), once per day.
|
|
12
12
|
// modelscope.ai is the same backend with the same paths and the same
|
|
13
13
|
// daily_active rule, but separate accounts and separate tokens: measured, an
|
|
14
14
|
// ms- token authenticates only on the site it came from.
|
|
15
15
|
//
|
|
16
16
|
// The plugin serves no chat models: inference keeps using the API-key modelscope
|
|
17
|
-
// preset in providers.json.
|
|
17
|
+
// preset in providers.json. This provider exists only to carry the sign-in the
|
|
18
18
|
// check-in runs as.
|
|
19
19
|
|
|
20
20
|
import { readFileSync } from "node:fs"
|
|
21
21
|
import { homedir } from "node:os"
|
|
22
22
|
import { isAbsolute, join } from "node:path"
|
|
23
23
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
// 24-hour expiry, so a second copy of the hooks would only be a second place
|
|
29
|
-
// for them to drift.
|
|
24
|
+
// One provider serves both sites: a token's site is a fact about the token,
|
|
25
|
+
// not about the card, so the plugin finds it per account instead of asking the
|
|
26
|
+
// user to pick a provider. A profile carries what differs between the sites —
|
|
27
|
+
// the origin, the plan line the card shows, and the word a 401 uses.
|
|
30
28
|
const CN = {
|
|
31
29
|
site: "https://modelscope.cn",
|
|
32
|
-
id: "msmoli",
|
|
33
|
-
name: "ModelScope 魔粒",
|
|
34
30
|
plan: "魔搭社区",
|
|
35
31
|
where: "国内站",
|
|
36
32
|
}
|
|
37
33
|
const INTL = {
|
|
38
34
|
site: "https://modelscope.ai",
|
|
39
|
-
id: "msmoli-intl",
|
|
40
|
-
name: "ModelScope 魔粒(国际版)",
|
|
41
35
|
plan: "魔搭社区(国际版)",
|
|
42
36
|
where: "国际站",
|
|
43
37
|
}
|
|
38
|
+
const SITES = [CN, INTL]
|
|
39
|
+
|
|
40
|
+
// The provider's id and name: one card, whatever mix of accounts it holds.
|
|
41
|
+
const ID = "msmoli"
|
|
42
|
+
const NAME = "ModelScope 魔粒"
|
|
43
|
+
|
|
44
|
+
// sites remembers which site each token signs in to, for the life of the
|
|
45
|
+
// process. Finding it costs one of the reads the hook was about to make anyway
|
|
46
|
+
// — a 401 is the only answer that rules a site out — and asking again on every
|
|
47
|
+
// poll would double every read. Bounded like `names`: a host is long-lived, and
|
|
48
|
+
// tokens come and go.
|
|
49
|
+
const sites = new Map()
|
|
50
|
+
const SITES_MAX = 64
|
|
51
|
+
const rememberSite = (token, profile) => {
|
|
52
|
+
sites.delete(token)
|
|
53
|
+
sites.set(token, profile)
|
|
54
|
+
while (sites.size > SITES_MAX) sites.delete(sites.keys().next().value)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// profileForSite maps a value kept on a sign-in back to its site. Anything
|
|
58
|
+
// else — an older host's field, a hand-edited file — is ignored and the token
|
|
59
|
+
// probed again rather than trusted.
|
|
60
|
+
const profileForSite = (v) => (typeof v === "string" ? SITES.find((p) => p.site === v) : undefined)
|
|
44
61
|
|
|
45
62
|
const LOGIN_INFO = "/api/v1/users/login/info"
|
|
46
63
|
const BALANCE = "/openapi/v1/magicubes/balance"
|
|
47
64
|
const TXNS = "/openapi/v1/magicubes/transactions"
|
|
48
65
|
|
|
66
|
+
// The sign-in is the site's own web session, so every request carries a
|
|
67
|
+
// browser's UA rather than a bare fetch one — a bare UA is the one shape
|
|
68
|
+
// measured to get a different answer. The string is fixed on purpose (the
|
|
69
|
+
// suite pins it whole): it names a browser version rather than pretending to
|
|
70
|
+
// be one, and nothing here fingerprints beyond it.
|
|
49
71
|
const UA =
|
|
50
72
|
"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"
|
|
51
73
|
|
|
52
|
-
//
|
|
74
|
+
// The daily-grant rules. This kind of credits expires in 24 hours; they do not
|
|
75
|
+
// accumulate.
|
|
53
76
|
const DAILY_RULES = ["daily_active", "aliyun_bindlogin"]
|
|
54
77
|
|
|
55
78
|
const TIMEOUT_MS = 8000
|
|
@@ -83,13 +106,22 @@ const USAGE_BUDGET_MS = 18 * 1000
|
|
|
83
106
|
// rule, one place — tokenOf and unusableKey must agree on it, or a key one of
|
|
84
107
|
// them accepts and the other refuses has no single answer to give the user.
|
|
85
108
|
const SENDABLE = /^[\x21-\x7e]+$/
|
|
86
|
-
|
|
109
|
+
// A key that is not a string is not a key: String() on an array or a number
|
|
110
|
+
// turns it into text that passes SENDABLE, and the sign-in's key then goes out
|
|
111
|
+
// as a Cookie — measured, `key: [token]` sent the token and `key: 1234…` sent
|
|
112
|
+
// the number. The host writes a string; anything else is a file this plugin
|
|
113
|
+
// could not read, and it is refused the way an unsendable key is, not sent.
|
|
114
|
+
const rawKey = (auth) => (auth?.type === "api" && typeof auth.key === "string" ? auth.key.trim() : "")
|
|
87
115
|
|
|
88
116
|
// tokenOf is the key this sign-in carries, or "" when there is none or it is
|
|
89
117
|
// not one we can send. The two are told apart by the caller so a key that is
|
|
90
118
|
// there and unusable does not read as "you never signed in".
|
|
91
119
|
const unusableKey = (auth) => {
|
|
92
|
-
|
|
120
|
+
if (auth?.type !== "api" || auth.key === undefined || auth.key === null) return false
|
|
121
|
+
// a key of the wrong type is a store this plugin cannot read, not an absent
|
|
122
|
+
// one: reported as unusable so the message says the token is unreadable
|
|
123
|
+
if (typeof auth.key !== "string") return true
|
|
124
|
+
const key = auth.key.trim()
|
|
93
125
|
return Boolean(key) && !SENDABLE.test(key)
|
|
94
126
|
}
|
|
95
127
|
const tokenOf = (auth) => {
|
|
@@ -133,7 +165,8 @@ const handleOf = (token) => fnv(token).toString(16)
|
|
|
133
165
|
// magpie's own fetch, behind a proxy, fails in Go's words — a plain Error
|
|
134
166
|
// "proxyconnect tcp: dial tcp 127.0.0.1:7890: i/o timeout" (host.js), not a
|
|
135
167
|
// TypeError — so it is named before the timeout test: its "i/o timeout" is the
|
|
136
|
-
// proxy's dial, and "
|
|
168
|
+
// proxy's dial, and the "request timed out (8 s)" wording would send the user
|
|
169
|
+
// after the network
|
|
137
170
|
// when the proxy is what is down.
|
|
138
171
|
function saidFailure(e, ctl, budget) {
|
|
139
172
|
if (e !== ctl.signal.reason && /^proxyconnect\b/i.test(String(e?.message ?? ""))) {
|
|
@@ -142,8 +175,10 @@ function saidFailure(e, ctl, budget) {
|
|
|
142
175
|
if (e === ctl.signal.reason || /timeout/i.test(String(e?.message ?? ""))) {
|
|
143
176
|
// Ceiling, not round: the budget can be down to a few hundred
|
|
144
177
|
// milliseconds when the hook's own deadline is what cut it short, and
|
|
145
|
-
// "
|
|
146
|
-
|
|
178
|
+
// "request timed out (0 s)" tells the user nothing about which of the two
|
|
179
|
+
// it was. The flag is how siteRead tells a timeout (the link, which the
|
|
180
|
+
// other site would wait out again) from a refusal (that site's answer).
|
|
181
|
+
return Object.assign(new Error(`请求超时(${Math.max(1, Math.ceil(budget / 1000))} 秒)`), { timeout: true })
|
|
147
182
|
}
|
|
148
183
|
if (e?.name === "TypeError" || /fetch failed/i.test(String(e?.message ?? ""))) {
|
|
149
184
|
return new Error("请求失败:网络不通、被代理拦截,或遇到了重定向")
|
|
@@ -151,6 +186,144 @@ function saidFailure(e, ctl, budget) {
|
|
|
151
186
|
return e
|
|
152
187
|
}
|
|
153
188
|
|
|
189
|
+
// siteRead is how the plugin learns a token's site: the remembered site is
|
|
190
|
+
// asked first, then the other one. Only a usable answer — a 200 that parses
|
|
191
|
+
// and does not say it failed — chooses a site and is remembered. A 401 drops
|
|
192
|
+
// the memory (another site may hold the token). Every other failure — a
|
|
193
|
+
// transport error, a proxy's 200 page, a 5xx, a 200 whose body denies the
|
|
194
|
+
// token, a 2xx that is not 200 (a 202 means accepted-and-not-done; gate()
|
|
195
|
+
// refuses it too) — moves the probe on to the other site: the remembered
|
|
196
|
+
// site being wrong (a re-keyed token), unreachable, or broken must not strand
|
|
197
|
+
// the account on it, and the domestic site can be broken on a network where
|
|
198
|
+
// the international one works. An API-level refusal (403 risk control, a 400
|
|
199
|
+
// on the request) is final: this site accepted the token and the answer is
|
|
200
|
+
// what the caller must report. A 429 is not that answer — a rate limit is the
|
|
201
|
+
// site's, not the token's — so the probe moves on and keeps it as the
|
|
202
|
+
// fallback, the way it treats a 5xx. The first failure is the one reported
|
|
203
|
+
// when neither site gives a usable answer, because it is the one the user was
|
|
204
|
+
// closest to (their token most often comes from the domestic site).
|
|
205
|
+
// awaitsSignIn: a 401 is the site refusing the token only when its answer
|
|
206
|
+
// reads as such — a bare "unauthorized" in the words ModelScope's login and
|
|
207
|
+
// API layers use, an empty object (the token read, nothing said), or a
|
|
208
|
+
// status-only answer. Anything else is the path answering: a proxy's
|
|
209
|
+
// "proxy authentication required", a gateway's title, an HTML page, some
|
|
210
|
+
// other object with no such word in it. Measured: a proxy's JSON 401 had the
|
|
211
|
+
// card tell the user the sign-in expired, and a re-login would not have
|
|
212
|
+
// changed the answer. A refusal is the default because it is what the site
|
|
213
|
+
// itself sends; the answer has to excuse it to be read as the path's.
|
|
214
|
+
const pathWords = /proxy|gateway|www-authenticate|re-?authenticate|\bbasic\b/i
|
|
215
|
+
// a path naming itself, as a whole word: in the words of a body or in a field
|
|
216
|
+
// name ("proxy": "upstream"). The 401's words and the 403's envelope both ask.
|
|
217
|
+
// an upstream's "Upstream returned status 401" is not a path naming itself: a
|
|
218
|
+
// site reads the body above it, and what a proxy puts there is its own words
|
|
219
|
+
const PATH_MARK = /(?<![a-z])(?:proxy|gateway|re-?authenticate)(?![a-z])/i
|
|
220
|
+
const signInWords = /unauthori[sz]ed|invalid (?:access )?token|token (?:is )?(?:invalid|expired|revoked)|invalid authentication|authorization header|not (?:logged|signed) in|unauthenticated|auth(?:entication)? (?:failed|required)|\b(?:expired|revoked)\b|\b401\b|登录|未登录|失效|过期|令牌|凭证|鉴权|认证/i
|
|
221
|
+
const awaitsSignIn = (body) => {
|
|
222
|
+
if (body === null || typeof body !== "object") return false
|
|
223
|
+
// an empty object is a refusal: a site that answers 401 with nothing in it
|
|
224
|
+
// has refused the token, and both sites refusing is a dead token (pinned by
|
|
225
|
+
// "a forgotten site is dropped from memory when the other site refuses
|
|
226
|
+
// too"). A proxy answering 401 with `{}` is indistinguishable from that; the
|
|
227
|
+
// cost of reading it as a refusal is a re-login prompt, not the token
|
|
228
|
+
// (a body that only names a path — {"message":"unauthorized","proxy":
|
|
229
|
+
// "upstream"} — is the path's: its names count as its words, and a field
|
|
230
|
+
// called "proxy" said "proxy" whatever its value)
|
|
231
|
+
const keys = Object.keys(body)
|
|
232
|
+
if (keys.length === 0) return true
|
|
233
|
+
if (/proxy|gateway/i.test(keys.join(" "))) return false
|
|
234
|
+
// ModelScope's login layer answers in Pascal case ({"Code":401,"Success":false,
|
|
235
|
+
// "Message":"…"}), the OpenAPI layer in lower case, and some messages are
|
|
236
|
+
// Chinese; all of them are read (a numeric or string code of 401 included)
|
|
237
|
+
// once a body that names a path has been set aside: a proxy's 401 carries a
|
|
238
|
+
// code of its own, and a code is not evidence about who answered
|
|
239
|
+
const said = [...keys.flatMap((k) => (typeof body[k] === "string" ? [k, body[k]] : [k])), body.error_description, body.detail, body.Detail, body.title, body.Title]
|
|
240
|
+
.filter((x) => typeof x === "string")
|
|
241
|
+
.join(" ")
|
|
242
|
+
if (pathWords.test(said) || PATH_MARK.test(said)) return false
|
|
243
|
+
const code = body.code ?? body.Code
|
|
244
|
+
if (String(code ?? "") === "401") return true
|
|
245
|
+
return said !== "" && signInWords.test(said)
|
|
246
|
+
}
|
|
247
|
+
async function siteRead(token, path, deadline = Infinity) {
|
|
248
|
+
const known = sites.get(token)
|
|
249
|
+
const order = known ? [known, ...SITES.filter((p) => p !== known)] : SITES
|
|
250
|
+
// A parsed 4xx/429/5xx that is not an answer is still the site *saying
|
|
251
|
+
// something*, and it beats a transport error from the other site ("risk
|
|
252
|
+
// control" is more useful than "check your proxy"). It is kept as `answer`
|
|
253
|
+
// while the loop goes on: the site that gave it may not be the one holding
|
|
254
|
+
// this token (a stale `site`, a re-keyed sign-in, a middlebox's 404).
|
|
255
|
+
let answer
|
|
256
|
+
// `said` is a parsed reply that is not an answer (429, 5xx, a 200 that says
|
|
257
|
+
// it failed); `down` is a transport error. They are kept apart so the
|
|
258
|
+
// promise above holds in both directions: whatever the site said beats the
|
|
259
|
+
// link failing, whichever order the two arrive in. `first` is then always a
|
|
260
|
+
// parsed reply (or nothing), and the dead link is the last thing reported.
|
|
261
|
+
let said
|
|
262
|
+
let down
|
|
263
|
+
for (const profile of order) {
|
|
264
|
+
let got
|
|
265
|
+
try {
|
|
266
|
+
got = await json(profile.site, token, path, deadline)
|
|
267
|
+
} catch (e) {
|
|
268
|
+
// A timeout is the link, not the site, and the remembered site may be
|
|
269
|
+
// the stale half of a re-keyed sign-in — the same state the 401 branch
|
|
270
|
+
// exists for. The second read is bounded by the hook's own deadline, so
|
|
271
|
+
// a dead network costs at most the budget it would have spent anyway;
|
|
272
|
+
// giving up here left an account on a wrong or unreachable site all day.
|
|
273
|
+
// a parsed answer from the other site outranks this (see below): the
|
|
274
|
+
// link being down says nothing about the account
|
|
275
|
+
down ??= e instanceof Error ? { error: e } : { error: new Error("") }
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (got.res.status === 401 && got.parsed && awaitsSignIn(got.body)) {
|
|
279
|
+
// A 401 where the token was accepted before: the sign-in may have been
|
|
280
|
+
// re-keyed with a token of the other site, or the remembered site may be
|
|
281
|
+
// stale (a kept `site` value, a proxy's 200 cached as an answer). A 401
|
|
282
|
+
// that is not ModelScope's own answer is a middlebox's challenge, not
|
|
283
|
+
// the token being refused — it falls through like any other non-answer.
|
|
284
|
+
// Measured: a proxy that answered 401 with its own JSON had the card
|
|
285
|
+
// tell the user the sign-in expired, and a re-login would not have
|
|
286
|
+
// changed the answer.
|
|
287
|
+
// A 401 is not evidence the token is dead by itself: every token answers
|
|
288
|
+
// 401 on the site it does not belong to, so a stale `site` value or a
|
|
289
|
+
// re-keyed sign-in looks exactly like a revoked token. The walk's own
|
|
290
|
+
// end says it (no site answered); this branch only forgets the site and
|
|
291
|
+
// lets the other one answer.
|
|
292
|
+
//
|
|
293
|
+
// A sibling read of the same token may have finished in between: this
|
|
294
|
+
// one captured its order before the other site was known, and its 401
|
|
295
|
+
// arrives after the sibling confirmed a site. Deleting then drops that
|
|
296
|
+
// confirmation — the token reads as unknown for a probe it already paid
|
|
297
|
+
// for, and an expiry decision can land on a live sign-in. The site is
|
|
298
|
+
// forgotten only while it is still the one in memory.
|
|
299
|
+
if (profile === known && sites.get(token) === profile) sites.delete(token)
|
|
300
|
+
continue
|
|
301
|
+
}
|
|
302
|
+
if (got.parsed && got.res.status === 200) {
|
|
303
|
+
if (okOf(got.body)) {
|
|
304
|
+
rememberSite(token, profile)
|
|
305
|
+
return { profile, ...got }
|
|
306
|
+
}
|
|
307
|
+
said ??= { answer: { profile, ...got } }
|
|
308
|
+
continue
|
|
309
|
+
}
|
|
310
|
+
// A 4xx is the site answering — but a 404/405/451 from a middlebox, or a
|
|
311
|
+
// 400/403 from the site that does not hold this token, is no better
|
|
312
|
+
// evidence than a 5xx. Keep it as the best answer and let the other site
|
|
313
|
+
// answer too; a genuine risk-control 403 on the right site still ends up
|
|
314
|
+
// as the report when the other site has nothing better to say.
|
|
315
|
+
if (got.parsed && got.res.status >= 400 && got.res.status < 500 && got.res.status !== 429) {
|
|
316
|
+
answer ??= { profile, ...got }
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
said ??= { answer: { profile, ...got } }
|
|
320
|
+
}
|
|
321
|
+
if (answer) return answer
|
|
322
|
+
if (said) return said.answer
|
|
323
|
+
if (down) throw down.error
|
|
324
|
+
throw deadToken("任一站点")
|
|
325
|
+
}
|
|
326
|
+
|
|
154
327
|
async function json(site, token, path, deadline = Infinity) {
|
|
155
328
|
const left = deadline - Date.now()
|
|
156
329
|
if (left <= 0) throw new Error("预算已用完,本次请求未发出")
|
|
@@ -165,8 +338,9 @@ async function json(site, token, path, deadline = Infinity) {
|
|
|
165
338
|
ctl.signal.addEventListener("abort", () => reject(ctl.signal.reason), { once: true })
|
|
166
339
|
})
|
|
167
340
|
aborted.catch(() => {})
|
|
341
|
+
let res
|
|
168
342
|
try {
|
|
169
|
-
|
|
343
|
+
res = await Promise.race([
|
|
170
344
|
aborted,
|
|
171
345
|
fetch(site + path, {
|
|
172
346
|
// every redirect is refused, the site's own included: one that left
|
|
@@ -189,8 +363,26 @@ async function json(site, token, path, deadline = Infinity) {
|
|
|
189
363
|
try {
|
|
190
364
|
body = text ? JSON.parse(text) : {}
|
|
191
365
|
} catch {}
|
|
192
|
-
|
|
366
|
+
// an empty body is not an answer at all: a bare 401 from a proxy is the
|
|
367
|
+
// path, not the site saying the token is refused, and reading it as "{}"
|
|
368
|
+
// (parsed, an object) signed the account out over a middlebox's refusal
|
|
369
|
+
return { res, body, parsed: body !== null && (text !== "" || res.status === 200) }
|
|
193
370
|
} catch (e) {
|
|
371
|
+
// An answer we stopped reading is still live: a declared length over the
|
|
372
|
+
// cap is refused before a byte is read, and the socket keeps streaming
|
|
373
|
+
// into a body nobody consumes once the timer is cleared — the plugin has
|
|
374
|
+
// no hold on the transport at all. Cancel the unconsumed body and abort
|
|
375
|
+
// the request, so it ends with this call.
|
|
376
|
+
// The abort does NOT carry e: saidFailure reads "e is the abort reason" as
|
|
377
|
+
// a timeout, and aborting with "响应过大" would relabel the refusal.
|
|
378
|
+
if (res) {
|
|
379
|
+
try {
|
|
380
|
+
await res.body?.cancel?.()
|
|
381
|
+
} catch {}
|
|
382
|
+
try {
|
|
383
|
+
ctl.abort(new Error("read abandoned"))
|
|
384
|
+
} catch {}
|
|
385
|
+
}
|
|
194
386
|
throw saidFailure(e, ctl, budget)
|
|
195
387
|
} finally {
|
|
196
388
|
// the answer is here: the timer has nothing left to guard, and one left
|
|
@@ -363,11 +555,13 @@ function cnTime(value) {
|
|
|
363
555
|
return Number.isFinite(at) ? new Date(at).toISOString() : ""
|
|
364
556
|
}
|
|
365
557
|
|
|
366
|
-
// ──
|
|
367
|
-
// magpie
|
|
368
|
-
//
|
|
369
|
-
//
|
|
370
|
-
//
|
|
558
|
+
// ── Check-in window ───────────────────────────────────────────────────────
|
|
559
|
+
// magpie asks once at the Beijing-day rollover and retries a failure every 30
|
|
560
|
+
// minutes; the plugin has no timer of its own (see the README's "upstream"
|
|
561
|
+
// section). So before today's target moment for this account arrives, the only
|
|
562
|
+
// answer is failed — that is the one outcome the host retries; every other
|
|
563
|
+
// outcome ends the day there and then. That overnight ask-and-answer sends no
|
|
564
|
+
// request; the claim itself is left to the ask during working hours.
|
|
371
565
|
const WINDOW_DEFAULT = ["08:30", "10:00"]
|
|
372
566
|
|
|
373
567
|
// LAST is the latest minute the window's end is capped at. The host asks
|
|
@@ -496,7 +690,12 @@ const names = new Map()
|
|
|
496
690
|
const NAMES_MAX = 64
|
|
497
691
|
|
|
498
692
|
function rememberName(token, name) {
|
|
499
|
-
|
|
693
|
+
// An empty name is remembered too. "The server gave no usable name" is a
|
|
694
|
+
// fact as much as a name is: before this, `names` never held it, so the
|
|
695
|
+
// "claimed and the name is known" shortcut never took and every provisional
|
|
696
|
+
// re-ask in a morning touched login/info again — the dozen extra sign-ins
|
|
697
|
+
// the shortcut exists to prevent. A read that failed does not come here, so
|
|
698
|
+
// a later ask still tries once more.
|
|
500
699
|
names.delete(token)
|
|
501
700
|
names.set(token, name)
|
|
502
701
|
while (names.size > NAMES_MAX) names.delete(names.keys().next().value)
|
|
@@ -568,10 +767,6 @@ function aliasOf(id, dir, label) {
|
|
|
568
767
|
return ""
|
|
569
768
|
}
|
|
570
769
|
|
|
571
|
-
// rememberAccountName tells magpie the account's own name, so its card reads
|
|
572
|
-
// "Mison" instead of "API key …a1b2". The sign-in is written as it already
|
|
573
|
-
// is — the same key, no second copy of the token — with the name beside it;
|
|
574
|
-
// an account that has one already is left alone.
|
|
575
770
|
// maskToken hides all but the ends of a token, for messages that mention one.
|
|
576
771
|
// The log is magpie's own, but a token has no business in it whole.
|
|
577
772
|
const maskToken = (token) =>
|
|
@@ -595,23 +790,61 @@ const maskToken = (token) =>
|
|
|
595
790
|
// matched is a piece of the real key, not a shape, so a key broken across two
|
|
596
791
|
// lines is still the key, and masking it whole is the point.
|
|
597
792
|
//
|
|
598
|
-
// The matching runs over the
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
793
|
+
// The matching runs over the alphanumeric characters alone, in one pass, and
|
|
794
|
+
// against the token's own alphanumeric run. Collecting "the characters the
|
|
795
|
+
// token is made of" instead let a character the token genuinely holds — the
|
|
796
|
+
// "-" of every ms- key — be used as a cutter: a server echoing the key with a
|
|
797
|
+
// "-" every 11 characters produced runs that were each under MIN_ECHO, and
|
|
798
|
+
// the whole key reached the card. Punctuation is now a separator whether the
|
|
799
|
+
// token holds it or not, and only its letters and digits say "this is the
|
|
800
|
+
// key". Everything else — whitespace, a zero-width space, a comma, a middle
|
|
801
|
+
// dot, an emoji, or the key's own hyphen — is stepped over and the pieces on
|
|
802
|
+
// either side joined for the comparison. Skipping them is what keeps an echo
|
|
803
|
+
// broken into short chunks whole, and a chunk under MIN_ECHO from surviving
|
|
804
|
+
// on its own. A whitelist of separators cannot do this: the set is open, and
|
|
805
|
+
// the one character it misses is the one the server uses.
|
|
806
|
+
//
|
|
807
|
+
// Stepping over a character as the scan went — and restarting one position
|
|
808
|
+
// later when the run came up short — made a run of them cost its own length
|
|
809
|
+
// at every position it contained: a 256 KiB body of newlines (a proxy's page,
|
|
810
|
+
// or a server echoing one) took minutes, inside a hook whose whole budget is
|
|
811
|
+
// 25 seconds, and the plugin host's event loop with it. Collected first, the
|
|
604
812
|
// same text is scanned once and the spans are put back where they were.
|
|
605
|
-
|
|
813
|
+
// MIN_ECHO is how much of the key must sit together before the text is taken
|
|
814
|
+
// for an echo. Ten rather than twelve, so that "a piece of at least 11
|
|
815
|
+
// characters of the key" is masked: with the scan starting one character past
|
|
816
|
+
// a short run (see maskEchoes below), a floor of 12 missed a run of 11 that
|
|
817
|
+
// opened a chunked echo. Ten still leaves an ordinary short word ("ms-dos",
|
|
818
|
+
// an 8-character slice) alone. The final review then showed the echo split
|
|
819
|
+
// into 8-character groups joined by one letter: each group is under this
|
|
820
|
+
// floor, so the mask steps over a single filler inside a run instead of
|
|
821
|
+
// restarting, and the floor applies to the joined run.
|
|
822
|
+
const MIN_ECHO = 10
|
|
823
|
+
// fold is the form an echo is compared in: NFKC (a full-width "ms" is the
|
|
824
|
+
// same key typed on another keyboard), lower case (a server that upper-cases
|
|
825
|
+
// its log is still echoing the key), and letters and digits only (everything
|
|
826
|
+
// else is a separator the echo inserted). Case and compatibility are not a
|
|
827
|
+
// change of secret, and comparing byte for byte left the whole key on the
|
|
828
|
+
// card when either changed.
|
|
829
|
+
const fold = (s) => (typeof s === "string" ? s.normalize("NFKC").toLowerCase().replace(/[^a-z0-9]/g, "") : "")
|
|
606
830
|
const maskEchoes = (s, token) => {
|
|
607
|
-
if (typeof token !== "string"
|
|
831
|
+
if (typeof token !== "string") return s
|
|
832
|
+
const flat = fold(token)
|
|
833
|
+
if (flat.length < MIN_ECHO) return s
|
|
834
|
+
// at/end are the source text's bounds for each folded character. One source
|
|
835
|
+
// code point can fold into more than one ("fi" is "fi"), so the end is kept
|
|
836
|
+
// per entry rather than derived from the folded length.
|
|
608
837
|
const vis = []
|
|
609
838
|
const at = []
|
|
839
|
+
const end = []
|
|
610
840
|
for (let i = 0; i < s.length; ) {
|
|
611
841
|
const c = String.fromCodePoint(s.codePointAt(i))
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
842
|
+
for (const f of c.normalize("NFKC").toLowerCase()) {
|
|
843
|
+
if (f >= "a" && f <= "z" || f >= "0" && f <= "9") {
|
|
844
|
+
vis.push(f)
|
|
845
|
+
at.push(i)
|
|
846
|
+
end.push(i + c.length)
|
|
847
|
+
}
|
|
615
848
|
}
|
|
616
849
|
i += c.length
|
|
617
850
|
}
|
|
@@ -620,31 +853,68 @@ const maskEchoes = (s, token) => {
|
|
|
620
853
|
for (let k = 0; k < vis.length; ) {
|
|
621
854
|
let piece = ""
|
|
622
855
|
let v = k
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
v
|
|
856
|
+
let fillers = 0
|
|
857
|
+
while (v < vis.length) {
|
|
858
|
+
if (flat.includes(piece + vis[v])) {
|
|
859
|
+
piece += vis[v]
|
|
860
|
+
v++
|
|
861
|
+
continue
|
|
862
|
+
}
|
|
863
|
+
// Characters the token does not hold at this point, between two pieces
|
|
864
|
+
// of it: a server that prints "01234567x 89abcde0x …" writes
|
|
865
|
+
// 8-character pieces under MIN_ECHO and a letter between them, and the
|
|
866
|
+
// pieces together are the key. Up to as many characters as the run has
|
|
867
|
+
// already matched are stepped over — an echo is mostly the key — and the
|
|
868
|
+
// run resumes at the first of them that continues it. A filler may only
|
|
869
|
+
// be stepped over *inside* a run: skipping a leading character before
|
|
870
|
+
// anything matched swallowed the word in front of the key ("x ms-…" lost
|
|
871
|
+
// its x). One filler was the first cut; two in a row then went through
|
|
872
|
+
// ("01234567xx 89abcdefxx …" kept the whole body).
|
|
873
|
+
if (piece.length === 0) break
|
|
874
|
+
const stop = Math.min(vis.length, v + 1 + (piece.length - fillers))
|
|
875
|
+
let hit = -1
|
|
876
|
+
for (let j = v + 1; j < stop; j += 1) if (flat.includes(piece + vis[j])) { hit = j; break }
|
|
877
|
+
// one character is stepped over however little has matched (a server's
|
|
878
|
+
// single letter between pieces); more than one only when the run is
|
|
879
|
+
// already long enough to be an echo under the lower floor. Without that
|
|
880
|
+
// second half, "said: aaaa1111-b" matched the last s of "said" and then
|
|
881
|
+
// skipped "id" to join the piece, and a 9-character slice that the floor
|
|
882
|
+
// is meant to leave alone was masked as if it were longer.
|
|
883
|
+
if (hit < 0 || (hit - v > 1 && piece.length < MIN_ECHO - 2)) break
|
|
884
|
+
fillers += hit - v
|
|
885
|
+
v = hit
|
|
626
886
|
}
|
|
887
|
+
// The floor counts the key's own characters, not the characters the echo
|
|
888
|
+
// wrote them in: a slice carrying its own hyphens ("1-bbbb-2222-") is
|
|
889
|
+
// twelve characters on the card and nine of the key, and nine is under the
|
|
890
|
+
// floor by design — a fragment that short is not the key, and counting
|
|
891
|
+
// punctuation would mask ordinary hyphenated text ("2026-11-08" is eight
|
|
892
|
+
// of them). The README says so.
|
|
627
893
|
if (piece.length >= MIN_ECHO) {
|
|
628
|
-
//
|
|
629
|
-
//
|
|
894
|
+
// The span of the whole run, the characters skipped between its pieces
|
|
895
|
+
// included. What sat between the last character written and the run's
|
|
896
|
+
// first one — whitespace skipped while matching — is written out first,
|
|
897
|
+
// so masking does not join two words that the echo had apart.
|
|
898
|
+
out += s.slice(from, at[k])
|
|
630
899
|
out += maskToken(token)
|
|
631
|
-
from =
|
|
900
|
+
from = end[v - 1]
|
|
632
901
|
k = v
|
|
633
902
|
} else {
|
|
634
|
-
out += s.slice(from,
|
|
635
|
-
from =
|
|
903
|
+
out += s.slice(from, end[k])
|
|
904
|
+
from = end[k]
|
|
636
905
|
k++
|
|
637
906
|
}
|
|
638
907
|
}
|
|
639
908
|
return out + s.slice(from)
|
|
640
909
|
}
|
|
641
910
|
|
|
642
|
-
//
|
|
643
|
-
//
|
|
644
|
-
//
|
|
645
|
-
//
|
|
646
|
-
const invisible = (c) => /[\p{Cc}\p{Cf}]/u.test(c)
|
|
911
|
+
// INV is the control or format class for the shape test, less the line breaks:
|
|
912
|
+
// a shape is all that is known there, and run on across a line it would mask
|
|
913
|
+
// the next line's words. maskEchoes no longer needs a separate predicate —
|
|
914
|
+
// it keeps the alphanumeric characters and skips everything else.
|
|
647
915
|
const INV = String.raw`(?:(?![\n\r])[\p{Cc}\p{Cf}])`
|
|
916
|
+
// the one place a token-shaped run is turned into a mask is maskShaped, below:
|
|
917
|
+
// the shape alone found a run; the body it carries is what decides.
|
|
648
918
|
|
|
649
919
|
// A key of another shape with an invisible character inside it reaches the card
|
|
650
920
|
// as two words once that character is turned into a space, and neither half
|
|
@@ -665,7 +935,547 @@ const GLUE = new RegExp(String.raw`(?<=[A-Za-z0-9+\/=_.-])${INV}(?=[A-Za-z0-9+\/
|
|
|
665
935
|
// the key, that is known here; the length floor keeps a short "ms-" word
|
|
666
936
|
// ("ms-dos") out of it. An invisible character between two of its characters
|
|
667
937
|
// does not end the run, for the reason maskEchoes steps over one.
|
|
668
|
-
|
|
938
|
+
//
|
|
939
|
+
// A key pasted with one space inside it ("ms-aaaa1111 bbbb-2222-…") is the
|
|
940
|
+
// same key, and each half is under every word floor on its own: that is the
|
|
941
|
+
// second arm, six characters, one space, five more. "ms-dos and ms-word" has
|
|
942
|
+
// no such shape (six and five characters either side of its real spaces).
|
|
943
|
+
// The body has to read as key material, not merely be eleven characters:
|
|
944
|
+
// an ordinary word after the front ("The ms-interviewing feature is
|
|
945
|
+
// unavailable" on the card, a nickname of the same) was masked whole and the
|
|
946
|
+
// nickname refused its write-back. A ModelScope key is hex or base64 with a
|
|
947
|
+
// digit in it, nearly always in hyphen-separated groups (aaaa1111-bbbb-2222-
|
|
948
|
+
// cccc-…); a word carries no digit and a version carries its digits as one
|
|
949
|
+
// dotted group ("ms-office 2016", "ms-cli 2026.11.9"). Either spelling does:
|
|
950
|
+
// 11+ that are all hex, or 11+ that carry a digit, and the body's last
|
|
951
|
+
// character joined to a letter or digit is a cut, not the end of a key
|
|
952
|
+
// ("ms-abc12345678abc" is one body, not "…5678" and "abc").
|
|
953
|
+
//
|
|
954
|
+
// A key cut by a space or a line break is still one key, and the two arms
|
|
955
|
+
// above each stopped at the first piece: "ms-vqmjh-75qa-651i -9x8x-2p4r-8t2n-
|
|
956
|
+
// qw7z" was masked to "ms-v…651i" with the rest of the key printed after it,
|
|
957
|
+
// and a key wrapped at a line break ("ms-vqmjh-75qa\n-651i-…") was not seen at
|
|
958
|
+
// all. The third arm reads on past a cut — a run of whitespace (a line break
|
|
959
|
+
// included) or a middot, then a piece that carries a digit — because the pieces
|
|
960
|
+
// of a cut are short and a key's own carry digits. It stops at a word of the
|
|
961
|
+
// text's ("… for provider"), at a version's dotted group ("ms-cli 2026.11.9",
|
|
962
|
+
// "ms-office 2016"), and at anything else that is not a piece. What is masked
|
|
963
|
+
// is still the run maskShaped was handed: the arm widens the shape, and keyBody
|
|
964
|
+
// decides, so text that does not read as a key is returned as written.
|
|
965
|
+
const keyChar = String.raw`(?:${INV}*[\x21-\x7e])`
|
|
966
|
+
const tokenShaped = new RegExp(
|
|
967
|
+
String.raw`m${INV}*s${INV}*-(?:${keyChar}{11,}|${keyChar}{0,}\s+(?=[\x21-\x7e]*\d)(?![\x21-\x7e]*\.)${keyChar}{5,})` +
|
|
968
|
+
String.raw`(?:(?:\s*[·•∙‧・⋅]\s*|\s+)(?!(?:m${INV}*s${INV}*-))(?![\x21-\x7e]*\.)(?=[\x21-\x7e]{5,})(?=[\x21-\x7e]*\d)[\x21-\x7e]+)*`,
|
|
969
|
+
"gu",
|
|
970
|
+
)
|
|
971
|
+
const keyBody = new RegExp(String.raw`(?:[0-9a-f]{11,}(?![0-9a-z])|(?=[\x21-\x7e]{11,})(?!\w)[\x21-\x7e]*\d)`)
|
|
972
|
+
// maskShaped is the replace both passes share: the shape finds a run that
|
|
973
|
+
// begins like a key; keyBody asks whether the run after the front reads as
|
|
974
|
+
// one. A run that does not is ordinary text and is handed back as written,
|
|
975
|
+
// untouched — the caller's own passes then judge it word by word.
|
|
976
|
+
const maskShaped = (m) => (keyBody.test(m) ? maskToken(m) : m)
|
|
977
|
+
// The vendor prefixes whose keys are not distinguishable from a name by its
|
|
978
|
+
// characters alone: the prefix is the evidence ("sk-" is Anthropic and OpenAI's,
|
|
979
|
+
// "sk_live_" Stripe's, "ghp_" GitHub's, "xoxb-" Slack's, "AKIA" AWS'). The body
|
|
980
|
+
// behind it is not asked — a name that starts with one of these is a key first.
|
|
981
|
+
// (a key can be echoed upper-cased — "SK-ANT-API03-…" — and that is the same
|
|
982
|
+
// key; the letters are compared without case)
|
|
983
|
+
const VENDOR = /^(?:sk-|sk_(?:live|test)_|gh[pousr]_|xox[abposr]-|akia|asia|aida|aliyun|ssh-rsa|-----begin)/i
|
|
984
|
+
const keyAt = (word, letters) => isKeyWord(word, letters) || VENDOR.test(word) || /^ms-/i.test(word)
|
|
985
|
+
// maskSpaced is the other reading of a piece of text: a key cut by whitespace
|
|
986
|
+
// is read as one key. Measured without it: a 40-character ghp_ token cut into
|
|
987
|
+
// four went out whole, a foreign ms- key cut the same way, and each piece
|
|
988
|
+
// cleared the word floors on its own. The check gets both readings — maskKeys
|
|
989
|
+
// per word, and this per run — and text that reads as a key under either is
|
|
990
|
+
// masked.
|
|
991
|
+
//
|
|
992
|
+
// Three readings, the same shapes bin/sync.mjs's maskCut reads: a run of words
|
|
993
|
+
// on one line (the body of a key cut by spaces — the key-shaped word at an
|
|
994
|
+
// end of it is where the mask begins, its pieces short or a vendor front); a
|
|
995
|
+
// pair either side of a line break (the break a wrapped copy leaves), judged
|
|
996
|
+
// joined; a narrow run of words that are all short pieces (a key written one
|
|
997
|
+
// character per word). A word already carrying a mask is not judged again.
|
|
998
|
+
const MASKED = "…"
|
|
999
|
+
// the length at which a run is long enough to be a key: the floor every
|
|
1000
|
+
// reading asks for, and all a mask needs to show
|
|
1001
|
+
const RUNCUT = 20
|
|
1002
|
+
// a middot or a bullet, one or two in a row, with at most one space on
|
|
1003
|
+
// either side: a cut's other spelling, and the separator a display draws
|
|
1004
|
+
// (the dot operator is a display's cut too: "1⋅0⋅0" is "100" spaced out)
|
|
1005
|
+
const MIDDOT_GAP = /^(?:[ \t\u00a0\u3000]?[·•∙‧・⋅]){1,2}[ \t\u00a0\u3000]?$/
|
|
1006
|
+
const MIDDOT_ONLY = /^[ \t\u00a0\u3000]?[·•∙‧・⋅][ \t\u00a0\u3000]?$/
|
|
1007
|
+
// a capitalised word ("Qwen3-Max") is a caption, not a piece of a key: two of
|
|
1008
|
+
// them either side of a middot are a list ("Qwen3-Max · Qwen3-Coder")
|
|
1009
|
+
const caption = (w) => /^[A-Z][a-z]{2,}/.test(w)
|
|
1010
|
+
// wordLike: a word a person wrote — letters only, a lower-case letter and a
|
|
1011
|
+
// vowel ("release", "today"). The pieces of a cut usually are not ("AKIA",
|
|
1012
|
+
// "qmjh", "ghp_", "NickName=[AKIA"), and a middot with a word on both sides
|
|
1013
|
+
// is the card's own separator
|
|
1014
|
+
const wordLike = (w) => /^[A-Za-z]+$/.test(w) && /[a-z]/.test(w) && /[aeiouy]/i.test(w)
|
|
1015
|
+
// the letters and digits a word ends with: a key can follow a frame
|
|
1016
|
+
// ("NickName=[AKIAIOSFODNN")
|
|
1017
|
+
const tailOf = (w) => w.match(/[A-Za-z0-9]+$/)?.[0] ?? ""
|
|
1018
|
+
// the same for a key-shaped head: "ms-" begins the key and a key may begin
|
|
1019
|
+
// with its front
|
|
1020
|
+
// the word's own key material: from a vendor front anywhere in it ("(ghp_1234",
|
|
1021
|
+
// "key=sk-ant-…"), or its last letters and digits. A front carries an
|
|
1022
|
+
// underscore or a hyphen, and a bare tail would cut "ghp_1234AbCd" at the
|
|
1023
|
+
// underscore, keeping what the front does not
|
|
1024
|
+
const FRONT_IN = /(?:sk-|sk_(?:live|test)_|gh[pousr]_|xox[abposr]-|ms-|akia|asia|aida)[\x21-\x7e]*$/i
|
|
1025
|
+
const keyFrontOf = (w) => (VENDOR.test(w) || /^ms-/i.test(w) ? w : w.match(FRONT_IN)?.[0] ?? tailOf(w))
|
|
1026
|
+
// a bare vendor stem cut off its punctuation ("ms", "sk", "ghp"): the key's
|
|
1027
|
+
// front, with the rest of the front in the next piece ("ms -vqmjh…")
|
|
1028
|
+
const STEM = /^(?:ms|sk|gh[opusr]|xox[a-z]?|akia|asia|aida)$/i
|
|
1029
|
+
// (a piece of a front — "gh" of "ghp_", "xo" of "xoxb-" — opens a key cut a
|
|
1030
|
+
// piece at a time: a word of prose it is not)
|
|
1031
|
+
const FRONT_PART = /^(?:sk|gh|ghp|gho|ghs|ghu|ghr|ms|xox|xoxa|xoxb|xoxp|xoxo|xoxr|xoxs|aki|akia|asi|asia|aid|aida)$/i
|
|
1032
|
+
const startProse = (w) => /^[a-z]{2,}$/.test(w) && !VENDOR.test(w) && !/^ms-/.test(w) && !STEM.test(w) && !FRONT_PART.test(w)
|
|
1033
|
+
function maskSpaced(s, letters) {
|
|
1034
|
+
if (s.length > 32 * 1024) return s
|
|
1035
|
+
const words = [...s.matchAll(/[\x21-\x7e]+/g)]
|
|
1036
|
+
if (words.length < 2) return s
|
|
1037
|
+
const at = (i) => words[i].index
|
|
1038
|
+
const end = (i) => words[i].index + words[i][0].length
|
|
1039
|
+
const gap = (i) => s.slice(end(i - 1), at(i))
|
|
1040
|
+
// (each gap judged once: a page of "1·1·1…" asked the same gap of three
|
|
1041
|
+
// readings at every word, and read itself to its square. Each reading keeps
|
|
1042
|
+
// its own table: one shared table answered a middot test with the space
|
|
1043
|
+
// test's result at the same index, and a space-joined key read as no run)
|
|
1044
|
+
const memo = (fn) => {
|
|
1045
|
+
const mem = new Map()
|
|
1046
|
+
return (i) => {
|
|
1047
|
+
let v = mem.get(i)
|
|
1048
|
+
if (v === undefined) mem.set(i, (v = fn(i)))
|
|
1049
|
+
return v
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
const midOf = memo((i) => MIDDOT_GAP.test(gap(i)))
|
|
1053
|
+
const onlyMid = memo((i) => MIDDOT_ONLY.test(gap(i)))
|
|
1054
|
+
const sp = memo((i) => spaces(gap(i)))
|
|
1055
|
+
const front = (w) => VENDOR.test(w) || /^ms-/i.test(w)
|
|
1056
|
+
// a gap a run is read across: one space, a no-break or ideographic one, a
|
|
1057
|
+
// line break, or a middot or a bullet (one or two, with at most one space
|
|
1058
|
+
// each side). The middot is a cut's other spelling as well as a display's
|
|
1059
|
+
// own separator; which one it is, is decided by the words on either side —
|
|
1060
|
+
// a key's front or a piece that does not read as a word is key material, a
|
|
1061
|
+
// word on both sides is prose ("release · notes"), and the cut still has to
|
|
1062
|
+
// be key-shaped below
|
|
1063
|
+
const spaces = (g) => /^[ \t \r\n]+$/.test(g) && g.length <= 2
|
|
1064
|
+
// a capitalised word ("Qwen3-Max") is a caption, not a piece of a key: two of
|
|
1065
|
+
// them either side of a middot are a list ("Qwen3-Max · Qwen3-Coder"), which
|
|
1066
|
+
// the card drew as one key before this
|
|
1067
|
+
const partsCut = (k, i) => midOf(k) && (front(words[i][0]) || /^[0-9a-f]{12,}/i.test(words[i][0]) || !wordLike(words[i][0]) || !wordLike(words[k][0]))
|
|
1068
|
+
// (a middot joined to the head's own front is a cut from the first piece:
|
|
1069
|
+
// "Ghp_Abcd1234·Efgh5678" is one key, whatever its pieces would read as
|
|
1070
|
+
// alone; the separator is a cut's for a front, not the card's)
|
|
1071
|
+
const runGap = (k, i) => sp(k) || partsCut(k, i) || (k === i + 1 && midOf(k) && front(words[i][0]))
|
|
1072
|
+
const out = []
|
|
1073
|
+
let from = 0
|
|
1074
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
1075
|
+
if (i > 0 && /^\r?\n$/.test(gap(i)) && end(i) - at(i - 1) >= 20) {
|
|
1076
|
+
const pair = words[i - 1][0] + words[i][0]
|
|
1077
|
+
if (!pair.includes(MASKED) && isKeyWord(pair, letters)) {
|
|
1078
|
+
out.push(s.slice(from, at(i - 1)), maskToken(pair))
|
|
1079
|
+
from = end(i)
|
|
1080
|
+
i += 1
|
|
1081
|
+
continue
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
// a character cut: every piece one character ("A K I A …"), or a vendor
|
|
1085
|
+
// front cut in two and then one character at a time ("G H P _ 1 2 …").
|
|
1086
|
+
const w1 = words[i][0]
|
|
1087
|
+
const cutGap = (k) => midOf(k) || sp(k)
|
|
1088
|
+
// (a piece behind a frame — the "(ghp" of "(ghp·_·1·…" — is a piece too)
|
|
1089
|
+
const w1p = w1.replace(/^[^A-Za-z0-9]+/, "")
|
|
1090
|
+
const pieceGap = (k) => (cutGap(k) || onlyMid(k)) && words[k][0].length === 1
|
|
1091
|
+
const pieceHead = /^[A-Za-z_]{1,3}$/.test(w1p) || front(keyFrontOf(w1))
|
|
1092
|
+
if (w1.length === 1 || (pieceHead && i + 1 < words.length && pieceGap(i + 1))) {
|
|
1093
|
+
let k = i
|
|
1094
|
+
while (k + 1 < words.length && k - i <= 96 && pieceGap(k + 1)) k += 1
|
|
1095
|
+
const joined = w1p + words.slice(i + 1, k + 1).map((w) => w[0]).join("")
|
|
1096
|
+
const capJoined = joined.replace(/[^A-Z0-9]/g, "")
|
|
1097
|
+
const rhythmic = /[A-Z][0-9]/.test(capJoined) || /[0-9][A-Z]/.test(capJoined)
|
|
1098
|
+
if (
|
|
1099
|
+
k > i &&
|
|
1100
|
+
// (a front that opens the run is the key's first piece, and a run of
|
|
1101
|
+
// one piece after it is the text's "· 4": the cut has two pieces more)
|
|
1102
|
+
(w1.length === 1 || k - i >= 2 || !front(keyFrontOf(w1))) &&
|
|
1103
|
+
joined.length >= 20 &&
|
|
1104
|
+
(isKeyWord(joined, letters) || (joined.match(/[A-Za-z0-9]+/g) ?? []).some((r) => keyRun(r, letters))) &&
|
|
1105
|
+
(!/^[A-Z0-9]+$/.test(joined) || rhythmic || /^AKIA[0-9A-Z]{16}$/.test(joined))
|
|
1106
|
+
) {
|
|
1107
|
+
out.push(s.slice(from, at(i)), maskToken(joined.replace(/[·•∙‧・⋅]/g, "")))
|
|
1108
|
+
from = end(k)
|
|
1109
|
+
i = k
|
|
1110
|
+
continue
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
// (a front behind a frame — "key=ghp_…", "(ghp_…" — is a word's start,
|
|
1114
|
+
// not a word of prose: the frame itself is the punctuation before it)
|
|
1115
|
+
const framedFront = /^[^A-Za-z0-9]+(?:sk-|sk_[a-z]+_|gh[pousr]_|xox[abposr]-|ms-|akia|asia|aida)/i.test(words[i][0])
|
|
1116
|
+
if (startProse(words[i][0]) && !framedFront) continue
|
|
1117
|
+
// two key-shaped words are two keys, and each is masked where it stands —
|
|
1118
|
+
// unless the two joined are one key cut once ("ghp_…EfG h9012…")
|
|
1119
|
+
// a ModelScope-fronted head followed by a short piece is not a pair: one
|
|
1120
|
+
// is the other's own body, and the run reads them as one key
|
|
1121
|
+
const msPair = /^ms-/i.test(words[i][0]) && !/^ms-[\x21-\x7e]{11,}$/i.test(words[i][0]) && i + 1 < words.length && words[i + 1][0].length <= 16
|
|
1122
|
+
// (two key-shaped pieces joined by a middot are one key's cut — "9f8e7d6c…
|
|
1123
|
+
// 7d6c·5b4a…" — and only two joined by a space are two keys)
|
|
1124
|
+
const pairOfKeys =
|
|
1125
|
+
i + 1 < words.length &&
|
|
1126
|
+
!msPair &&
|
|
1127
|
+
/^[ \t\u00a0\u3000]+$/.test(gap(i + 1)) &&
|
|
1128
|
+
keyAt(words[i][0], letters) &&
|
|
1129
|
+
keyAt(words[i + 1][0], letters) &&
|
|
1130
|
+
!(front(words[i][0]) && isKeyWord(words[i][0] + words[i + 1][0], letters))
|
|
1131
|
+
let j = i
|
|
1132
|
+
let last = i
|
|
1133
|
+
// the last word the run read into its body: a piece read to keep a key in
|
|
1134
|
+
// view is the key's ("…·789·0" lost its last "0"), while a word the run
|
|
1135
|
+
// stopped at is not
|
|
1136
|
+
let readTo = i
|
|
1137
|
+
let mixedCase = words[i][0].length >= 3 && /[a-z]/.test(words[i][0]) && /[A-Z]/.test(words[i][0])
|
|
1138
|
+
let body = words[i][0]
|
|
1139
|
+
let pieces = [body]
|
|
1140
|
+
let shapedBody = false
|
|
1141
|
+
// (a run of middots reads on to its cap, and whether a key is there is
|
|
1142
|
+
// asked of the body only once its length is a key's: a page of "1·1·1…"
|
|
1143
|
+
// answered it at every piece, and read itself to its square)
|
|
1144
|
+
let askedAt = 0
|
|
1145
|
+
let midKey = false
|
|
1146
|
+
// the run's pieces checked as they are read: every piece short enough or a
|
|
1147
|
+
// front, and none a word of seven or more letters (wordPieces)
|
|
1148
|
+
let okPieces = true
|
|
1149
|
+
let wordPieces = false
|
|
1150
|
+
// (a run of capitals and digits that reads as a key after one of its words
|
|
1151
|
+
// is read whatever it goes on to read: "A A A …" is a page, not a key cut
|
|
1152
|
+
// to its cap, and reading it on was a page of the card's budget)
|
|
1153
|
+
let found = false
|
|
1154
|
+
// a front written the way fronts are ("ghp_", "sk-", "ms-"): an upper-
|
|
1155
|
+
// cased vendor id ("AKIA…") is the key itself, and the words after it are
|
|
1156
|
+
// the text's — the prose rule below reads them as prose
|
|
1157
|
+
// (a middot-joined run is a key cut when the run reads as one whole:
|
|
1158
|
+
// "Ghp_Abcd1234·Efgh5678" is one, "file1 · file2 · …" is a list. A run
|
|
1159
|
+
// that is not one is read piece by piece from its head.)
|
|
1160
|
+
const joinedCut = (() => {
|
|
1161
|
+
let t = words[i][0]
|
|
1162
|
+
for (let q = i + 1; q < words.length && t.length < 64; q += 1) {
|
|
1163
|
+
if (!midOf(q) || words[q][0].length > 16) break
|
|
1164
|
+
t += words[q][0]
|
|
1165
|
+
}
|
|
1166
|
+
return t
|
|
1167
|
+
})()
|
|
1168
|
+
// (only a middot-joined run is asked: a whitespace run's "whole" reading is
|
|
1169
|
+
// the cut loop's own, piece by piece)
|
|
1170
|
+
const wholeRun = i + 1 < words.length && midOf(i + 1) && isKeyWord(joinedCut, letters)
|
|
1171
|
+
const headFront = wholeRun || front(keyFrontOf(words[i][0])) || STEM.test(words[i][0])
|
|
1172
|
+
// (a run built of capitals and digits alone is read on only while it is no
|
|
1173
|
+
// key yet: a page of "A A A …" reads as one key's shape at every length)
|
|
1174
|
+
// (a run of one- and two-character capitals — "A A A …", a page of them —
|
|
1175
|
+
// is read on: there is no word of it to end the run, and reading every
|
|
1176
|
+
// one of them was a page of the card's budget)
|
|
1177
|
+
// (a head of capitals and digits alone, the first word too: a vendor id
|
|
1178
|
+
// is read to its front's end, and a page of short words is not one)
|
|
1179
|
+
// (a head that is a vendor front, the first word too, or that carries a
|
|
1180
|
+
// digit: a key's pieces after a front are its own)
|
|
1181
|
+
let capsy = (headFront || /[0-9]/.test(body)) && /^[A-Z0-9]{1,2}$/.test(body)
|
|
1182
|
+
let tinyCaps = capsy
|
|
1183
|
+
for (let k = i + 1; !pairOfKeys && k < words.length && k - i <= 48 && runGap(k, i); k += 1) {
|
|
1184
|
+
last = k
|
|
1185
|
+
// a run already long enough to be a key is not extended on a guess: 20
|
|
1186
|
+
// characters of key material is the floor every reading below asks for.
|
|
1187
|
+
// Once a cut is found the run does go on, because the key does — the
|
|
1188
|
+
// mask has to cover all of a 32-character digest, not its first 20 —
|
|
1189
|
+
// and it stops at a hard cap so a long run of tiny words stays linear
|
|
1190
|
+
if (body.length >= 96) break
|
|
1191
|
+
// (a run that has already read as a key goes on to its cap only over
|
|
1192
|
+
// the separator it read it with: a space-joined run that read as one key
|
|
1193
|
+
// is read on — "ms-abcd… efgh …" is one key cut by spaces — and a page
|
|
1194
|
+
// of "A A A …" is let go)
|
|
1195
|
+
// (the tiny-capitals cap is the one below, at each piece read: asking it
|
|
1196
|
+
// here as well only made the page of "A A A \u2026" end one piece sooner)
|
|
1197
|
+
if (found && !midOf(k) && !/^[ \t\u00a0\u3000]+$/.test(gap(k))) break
|
|
1198
|
+
const x = words[k][0]
|
|
1199
|
+
// a lone hyphen is a cut of its own ("ms - 0123"), not a break
|
|
1200
|
+
if (!/[A-Za-z0-9]/.test(x) && x !== "-") break
|
|
1201
|
+
const mid = midOf(k)
|
|
1202
|
+
// the card's own separator ("Qwen3-Max · Qwen3-Coder") joins a list, not
|
|
1203
|
+
// pieces of one key: a key cut by middots is a key whose pieces are not
|
|
1204
|
+
// captions. A caption piece is read as its own text.
|
|
1205
|
+
// (a head that carries a key's front behind a frame — "NickName=[ghp_…"
|
|
1206
|
+
// — begins with a capital and a lower-case run like a caption's, and is
|
|
1207
|
+
// not one: the run is the key's, and ending it here left the front and
|
|
1208
|
+
// its first piece on the card)
|
|
1209
|
+
if (
|
|
1210
|
+
mid &&
|
|
1211
|
+
caption(x) &&
|
|
1212
|
+
caption(words[i][0]) &&
|
|
1213
|
+
!front(words[i][0]) &&
|
|
1214
|
+
!front(keyFrontOf(words[i][0])) &&
|
|
1215
|
+
!/^[0-9a-f]{12,}/i.test(words[i][0])
|
|
1216
|
+
)
|
|
1217
|
+
break
|
|
1218
|
+
mixedCase = mixedCase || (x.length >= 3 && /[a-z]/.test(x) && /[A-Z]/.test(x))
|
|
1219
|
+
// the readings below ask for a digit or a mixed-case word in the run, and
|
|
1220
|
+
// the key's own front decides the rest: a word of four or more lower-case
|
|
1221
|
+
// letters is prose ("sk-User joined the team") unless the run starts at a
|
|
1222
|
+
// key's front; a word of one to three is key material only while the run
|
|
1223
|
+
// has not yet read as a key ("ms-0123… for provider" keeps its frame)
|
|
1224
|
+
// a word of the run's own frame ("see … here", "the key … here"): four
|
|
1225
|
+
// letters or more, and nothing else — a word carrying digits or
|
|
1226
|
+
// capitals is key material, or a model's name ("Qwen3-Coder")
|
|
1227
|
+
// the run starts at a key's front written the way fronts are ("ghp_",
|
|
1228
|
+
// "sk-", "ms-"): an upper-cased vendor id ("AKIA…") is the key itself
|
|
1229
|
+
const frontHead = (VENDOR.test(words[i][0]) || /^ms-/i.test(words[i][0])) && /^[a-z]/.test(words[i][0])
|
|
1230
|
+
// (a piece of exactly four is a piece of a key cut into fours when the
|
|
1231
|
+
// run started at a front — "ms-vqmjh 75qa 651i …")
|
|
1232
|
+
// a word carrying a lower-case letter and nothing else is prose, wherever
|
|
1233
|
+
// the run started ("sk-User joined the team", "ms-foreign-… here"); a
|
|
1234
|
+
// piece of a key carries digits or is written in capitals, and a piece
|
|
1235
|
+
// of exactly four is a piece of a key cut into fours ("ms-v qmjh 75qa …")
|
|
1236
|
+
// (four letters that a key piece follows are a piece of that key:
|
|
1237
|
+
// "ms-v qmjh 75qa …" — the piece after it carries a digit or a capital)
|
|
1238
|
+
const moreKey = k + 1 < words.length && /[0-9A-Z]/.test(words[k + 1][0])
|
|
1239
|
+
// (a middot's pieces are read whatever they would read as alone: the
|
|
1240
|
+
// separator is a cut's, and "sk-a·nt-a·pi03" is a key)
|
|
1241
|
+
const prose = !mid && /^[a-z]{4,}$/.test(x) && (x.length > 4 || !frontHead || !moreKey)
|
|
1242
|
+
const fronty = front(keyFrontOf(x)) || STEM.test(x)
|
|
1243
|
+
// a word of the run's own text ends it once a cut has been read ("see
|
|
1244
|
+
// AKIA… here", "ms-foreign-… for provider"), and before that if the run
|
|
1245
|
+
// starts at a key's front
|
|
1246
|
+
if (prose && (headFront || j > i)) break
|
|
1247
|
+
const reads = headFront || fronty
|
|
1248
|
+
if (j > i && !reads && !/[0-9A-Z]/.test(x) && !mid && !(body.length >= 24 && !/^[a-z]{1,3}$/.test(x))) break
|
|
1249
|
+
if (!headFront && prose && (x.length > 4 || !fronty)) break
|
|
1250
|
+
// a key that has already read as one ends at a short word ("ms-0123…
|
|
1251
|
+
// for provider" keeps its frame): the frame is not part of the mask
|
|
1252
|
+
// a short word after a run that already reads as a key ends it ("123e…-…
|
|
1253
|
+
// not found", "ms-0123 for provider")
|
|
1254
|
+
if (/^[a-z]{1,3}$/.test(x) && (j > i || isKeyWord(body, letters))) break
|
|
1255
|
+
// (a middot's pieces are the key's: "9f8e·7d6c·…" is one key whatever
|
|
1256
|
+
// case its pieces are written in, and the run goes on to its end)
|
|
1257
|
+
if (j === i && body.length >= 4 * RUNCUT) break
|
|
1258
|
+
// a word of four or more lower-case letters inside the run is prose
|
|
1259
|
+
// ("sk-User joined the team"), not a piece of a key. A run of exactly
|
|
1260
|
+
// four is a piece as readily as a word ("ms-v qmjh 75qa …" is a key cut
|
|
1261
|
+
// into fours), so it ends the run only when the run does not start at a
|
|
1262
|
+
// key's front
|
|
1263
|
+
if (x.length >= 4 && /^[a-z]{4,}$/.test(x) && !/\d/.test(body) && !mid && (x.length > 4 || !frontHead)) break
|
|
1264
|
+
body += x
|
|
1265
|
+
pieces.push(x)
|
|
1266
|
+
readTo = k
|
|
1267
|
+
if (!(x.length <= 16 || front(x) || (x.length <= 64 && /\d/.test(x) && /[A-Za-z]/.test(x)) || tailOf(x) !== x)) okPieces = false
|
|
1268
|
+
// (a run of capitals and digits alone can be a key at any length, and is
|
|
1269
|
+
// read on: it is the one run that has no digit or case to go on)
|
|
1270
|
+
if (/^[A-Za-z]{7,}$/.test(x) && !/^[A-Z]+$/.test(x)) wordPieces = true
|
|
1271
|
+
// (a word of three or more capitals and digits alone is the key's own
|
|
1272
|
+
// piece: "AKIAIOSFODNN" — a page of "A A A …" has none, and a key
|
|
1273
|
+
// written without spaces keeps them)
|
|
1274
|
+
if (x.length >= 3 && /^[A-Z0-9]+$/.test(x) && /[A-Z][0-9]|[0-9][A-Z]/.test(body + x)) found = true
|
|
1275
|
+
capsy = capsy && /^[A-Z0-9]{1,2}$/.test(x)
|
|
1276
|
+
// (a page of "A A A …" reads as one key at every length, and is let go
|
|
1277
|
+
// after sixteen pieces — no key is cut into that many single capitals)
|
|
1278
|
+
if (capsy) tinyCaps += 1
|
|
1279
|
+
if (capsy && tinyCaps > 16) break
|
|
1280
|
+
// a ModelScope key written down before it was cut: its eleven
|
|
1281
|
+
// characters follow ms- at once, and the cut pieces are then read to
|
|
1282
|
+
// the end ("ms-vqmjh75qa 651i 9x8x …") however long the head is
|
|
1283
|
+
shapedBody = shapedBody || (headFront && letters && /^ms-\w{11}/i.test(body) && !/[\s\u3000]/.test(x))
|
|
1284
|
+
// a piece of seven or more letters on its own is a word, not a piece of
|
|
1285
|
+
// a key, and a run holding one is text ("Rebecca Deborah Bradford" is
|
|
1286
|
+
// mixed case and long enough to read as a key otherwise). A piece of 17
|
|
1287
|
+
// a ModelScope-shaped body ("ms-" and eleven characters) is a key the
|
|
1288
|
+
// moment it reads as one; the platform's own keys are shorter than the
|
|
1289
|
+
// twenty characters the other shapes need
|
|
1290
|
+
shapedBody =
|
|
1291
|
+
shapedBody ||
|
|
1292
|
+
headFront &&
|
|
1293
|
+
letters &&
|
|
1294
|
+
/^ms-/i.test(body) &&
|
|
1295
|
+
/\d/.test(body) &&
|
|
1296
|
+
body.length >= 14 &&
|
|
1297
|
+
!pieces.some((w) => /^[a-z]{1,3}$/.test(w))
|
|
1298
|
+
// (the pieces' join is the body: asked once here, not joined again for
|
|
1299
|
+
// every middot piece, which read a page of tiny pieces to its square)
|
|
1300
|
+
// (each piece's check is made when it is read — "okPieces" — so a body
|
|
1301
|
+
// that is no key of key length costs no second walk over its pieces)
|
|
1302
|
+
const bodyKey = body.length < 40 && isKeyWord(body, letters)
|
|
1303
|
+
const cut =
|
|
1304
|
+
(body.length >= 20 || shapedBody) &&
|
|
1305
|
+
!body.includes(MASKED) &&
|
|
1306
|
+
okPieces &&
|
|
1307
|
+
// a piece of seven or more letters is a word, unless it is written in
|
|
1308
|
+
// capitals: a vendor key's piece is ("AKIAIOSFOD · NN7EXAMPLE")
|
|
1309
|
+
// (a middot's piece is the key's whatever it reads as alone: a list of
|
|
1310
|
+
// words with numerals ("file1 · file2 · …") still has to read as one
|
|
1311
|
+
// key whole once its middots are read as spaces, and does not)
|
|
1312
|
+
!wordPieces &&
|
|
1313
|
+
!(mid && !bodyKey) &&
|
|
1314
|
+
(bodyKey || (body.match(/[A-Za-z0-9]+/g) ?? []).some((r) => keyRun(r, letters)))
|
|
1315
|
+
// an all-capitals run follows the same rule as the capitals reading
|
|
1316
|
+
if (cut) {
|
|
1317
|
+
const capRun = body
|
|
1318
|
+
if (!(/^[A-Z0-9]+$/.test(capRun) && !/^AKIA[0-9A-Z]{16}$/.test(capRun) && !/[A-Z][0-9]/.test(capRun) && !/[0-9][A-Z]/.test(capRun))) j = k
|
|
1319
|
+
}
|
|
1320
|
+
// a cut marked by a middot every few characters is a cut, and it goes on
|
|
1321
|
+
// to its cap ("ghp_12·34A·bC…" is a key the display drew a piece per
|
|
1322
|
+
// middot). It is the run's from its first piece when the head is a
|
|
1323
|
+
// key's front ("Ghp_Abcd1234·…": an upper-cased front is one too) — a
|
|
1324
|
+
// piece that does not read as a key on its own is still the key's —
|
|
1325
|
+
// and only from the cut it found otherwise ("…·p789": the pieces before
|
|
1326
|
+
// it are the text's, and the run there reads as one key before the
|
|
1327
|
+
// middot)
|
|
1328
|
+
// (the key begins at the head, as it does for a front, whatever the
|
|
1329
|
+
// middot did with the rest; the run still has to read as one key whole
|
|
1330
|
+
// once its middots are read as spaces — "file1 · file2 · …" is a list)
|
|
1331
|
+
// (a run of middots reads on to its cap. Without a front of its own, it
|
|
1332
|
+
// belongs to the mask once it reads as one key whole once — asked every
|
|
1333
|
+
// few characters, since a page of "1·1·1…" answers at every piece and
|
|
1334
|
+
// read itself to its square — and from there on to its end.)
|
|
1335
|
+
// (a middot every few characters is a cut's spelling, and it goes on to
|
|
1336
|
+
// its cap: five middots in a run is no text's "·" separator)
|
|
1337
|
+
if (mid && k - i >= 4) midKey = true
|
|
1338
|
+
if (mid && body.length >= RUNCUT && !body.includes(MASKED)) {
|
|
1339
|
+
if (front(words[i][0])) j = k
|
|
1340
|
+
else {
|
|
1341
|
+
if (!midKey && (askedAt === 0 || body.length - askedAt >= 4)) {
|
|
1342
|
+
askedAt = body.length
|
|
1343
|
+
midKey = isKeyWord(body, letters) || (body.match(/[A-Za-z0-9]+/g) ?? []).some((r) => keyRun(r, letters))
|
|
1344
|
+
}
|
|
1345
|
+
if (midKey) j = k
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
if (j === i) {
|
|
1350
|
+
continue
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
// where the key starts: the run's own head, and the first key-shaped word
|
|
1354
|
+
// of a run that begins in a frame ("the key AKIAIOSFODNN 7EXAMPLE"). A
|
|
1355
|
+
// word key-shaped on its own and longer than 16 is the whole key
|
|
1356
|
+
// ("ghp_…EfG h9012…"), not a front with a second key behind it
|
|
1357
|
+
let head = i
|
|
1358
|
+
// a run starts at its first word unless that word is only the frame the
|
|
1359
|
+
// key was written in ("the ms-…", "no key ghp_"); a word key-shaped on its
|
|
1360
|
+
// own ("sk-ant-api03-Eyai1pkJrF") is where the key begins, not the pieces
|
|
1361
|
+
// after it
|
|
1362
|
+
// (a word in a frame is judged by its own key material: "(ghp_…" is where
|
|
1363
|
+
// the key begins, and reading it as a bare "(" moved the head past it)
|
|
1364
|
+
// a vendor front at the word's own start is the word's start, braces or not
|
|
1365
|
+
const iHead = front(words[i][0]) ? words[i][0] : keyFrontOf(words[i][0])
|
|
1366
|
+
const iFrontAt = keyAt(iHead, letters) && !(letters === false && keyAt(words[i][0], letters))
|
|
1367
|
+
for (let q = i + 1; q <= j && !headFront && !shapedBody && !(iFrontAt || keyAt(words[i][0], letters)); q += 1) {
|
|
1368
|
+
const y = tailOf(words[q][0])
|
|
1369
|
+
if (y.length <= 16 && (keyAt(y, letters) || (y.length >= 8 && /\d/.test(y) && /[A-Z]/.test(y)))) {
|
|
1370
|
+
head = q
|
|
1371
|
+
break
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
const hp = words[head][0]
|
|
1375
|
+
// where the key's own text begins in the head word. A word with no frame
|
|
1376
|
+
// punctuation that reads as a key is the whole key — a UUID, an ms- key, a
|
|
1377
|
+
// slashed secret: the separators inside it are the key's ("123e4567-e89b-…"),
|
|
1378
|
+
// and cutting at the last letters-and-digits left most of it in the frame.
|
|
1379
|
+
// A frame's punctuation ("key=…", "(…)") hides the key behind it when the
|
|
1380
|
+
// frame carries no word of its own; a frame that does ("NickName=[") is
|
|
1381
|
+
// read from the word's letters and digits, as before.
|
|
1382
|
+
const frameCut = Math.max(0, ...'=:([{,"\''.split("").map((c) => hp.lastIndexOf(c) + 1))
|
|
1383
|
+
const framed = hp.slice(frameCut)
|
|
1384
|
+
const frameWord = /[A-Za-z0-9]{2,}/.test(hp.slice(0, frameCut))
|
|
1385
|
+
// a piece of a hex key cut by whitespace — "123e4567-e89b", "9f8e7d6c5b4a3928"
|
|
1386
|
+
// — is the key's from its own first character, not from its last
|
|
1387
|
+
// letters-and-digits: reading it as a tail began the run at "e89b" and left
|
|
1388
|
+
// "123e4567-" on the card (measured: twelve characters of a UUID where the
|
|
1389
|
+
// mask allows four). The hex letters are what tells the piece from a date
|
|
1390
|
+
// ("2026-11-10" has digits and hyphens but no a–f) and from a word.
|
|
1391
|
+
const hexPiece = /^[0-9a-f]+(?:-[0-9a-f]+)*$/i.test(hp) && /\d/.test(hp) && /[a-f]/i.test(hp) && hp.length >= 8
|
|
1392
|
+
const ht =
|
|
1393
|
+
frameCut === 0 && (keyAt(hp, letters) || hexPiece)
|
|
1394
|
+
? hp
|
|
1395
|
+
: hp.match(FRONT_IN)?.[0] ?? (frameCut > 0 && !frameWord && keyAt(framed, letters) ? framed : tailOf(hp) || hp)
|
|
1396
|
+
const hf = hp.slice(0, hp.length - ht.length)
|
|
1397
|
+
// the head is a stem of a front the run goes on with ("ms", "sk", "ms-":
|
|
1398
|
+
// the key begins at the stem), and its hyphen is the front's own
|
|
1399
|
+
// (a word that only starts a front — "(gh", "xa" of "xoxb-" — is one too)
|
|
1400
|
+
const stem = head === i && headFront && !front(hp) && !STEM.test(hp) && /^-*$/.test(hf) && FRONT_PART.test(hf)
|
|
1401
|
+
// the separators the key was cut by stay inside the mask (a digest is one
|
|
1402
|
+
// key, "9f8e·7d6c·…" as much as "9f8e 7d6c …"), but a middot or bullet
|
|
1403
|
+
// that is the card's own separator does not ("Qwen3-Max · 调用返还").
|
|
1404
|
+
// A run that begins in a frame ("ms-a 1A1…") starts at its head
|
|
1405
|
+
let rest = words.slice(head + 1, j + 1).map((w) => w[0])
|
|
1406
|
+
// a word after a middot that the run picked up only to keep reading is the
|
|
1407
|
+
// text's ("AKIA… · 4 次"): the mask stops at the last piece the key needed
|
|
1408
|
+
// (a head that is the text's last word has no word after it to read)
|
|
1409
|
+
// (the trim belongs to the card's own separator, "·" with a space on one
|
|
1410
|
+
// side: a bare middot is a cut's spelling and its pieces are the key's,
|
|
1411
|
+
// "12·3e·45·…·40·00" — trimming a piece dropped "00" from the mask and
|
|
1412
|
+
// left the head covering only the first piece)
|
|
1413
|
+
if (head + 1 < words.length && midOf(head + 1) && /^[ \t\u00a0\u3000]/.test(gap(head + 1))) {
|
|
1414
|
+
const endsText = s.slice(end(j)).trim() === ""
|
|
1415
|
+
// (a caption after a spaced middot is the card's own separator's next
|
|
1416
|
+
// entry — "Qwen3-Max · Qwen3-Coder", "qwen3-max · qwen3-max": a model's
|
|
1417
|
+
// name is no piece of a key, and the run is where it was)
|
|
1418
|
+
const text = (w) => caption(w) || (!endsText && w.length <= 2)
|
|
1419
|
+
// (every piece of the run must be read up to it, and the run still has
|
|
1420
|
+
/* eslint-disable-next-line no-irregular-whitespace */
|
|
1421
|
+
// to read as a key whole: "file1 · file2 · file3 · file4" is a list)
|
|
1422
|
+
while (rest.length > 1 && rest[rest.length - 1].length <= 4 && !isKeyWord([ht, ...rest].join(""), letters) && (text(rest[rest.length - 1]) || !isKeyWord(words.slice(i, j + 1).map((w) => w[0]).join(""), letters))) rest = rest.slice(0, -1)
|
|
1423
|
+
while (!endsText && rest.length > 0 && rest[rest.length - 1].length <= 2) rest = rest.slice(0, -1)
|
|
1424
|
+
}
|
|
1425
|
+
// a hyphen the cut left on the front's end ("ms-" then "0123") is the
|
|
1426
|
+
// front's own, and the key may begin with it
|
|
1427
|
+
if (stem && /^-$/.test(rest[0] ?? "")) rest[0] = ht + "-"
|
|
1428
|
+
const run = stem ? rest : [ht, ...rest]
|
|
1429
|
+
// the frame's closing punctuation is the frame's ("(ghp_…7890)", not
|
|
1430
|
+
// "(ghp_…90)"): the mask's last four characters are the key's own
|
|
1431
|
+
let whole = rest.length === words.slice(head + 1, j + 1).length
|
|
1432
|
+
// the run's last words can be the key's even when they added no shape of
|
|
1433
|
+
// their own ("…·789·0"): what the run read is masked. The mask is the
|
|
1434
|
+
// run's to its end — key-shaped from the head's own material ("ghp_") or
|
|
1435
|
+
// long enough to be a key — and the frame the run read past ("· 4 次") is
|
|
1436
|
+
// trimmed below. (A mask whose first twenty characters read as a key stops
|
|
1437
|
+
// at the cut it found; one that reads only at length is a piece that was
|
|
1438
|
+
// joined, and the pieces after it are the key's.)
|
|
1439
|
+
const head20 = ([ht, ...rest].join("").match(/[A-Za-z0-9]+/g) ?? []).some((r) => keyRun(r.length > 20 ? r.slice(0, 20) : r, letters))
|
|
1440
|
+
const long20 = [ht, ...rest].join("").length >= 20
|
|
1441
|
+
if (whole && readTo > j && !(head20 && long20) && isKeyWord([ht, ...rest].join(""), letters) && !/^[a-z]{3,}$/.test(words[readTo][0])) {
|
|
1442
|
+
rest = words.slice(head + 1, readTo + 1).map((w) => w[0])
|
|
1443
|
+
whole = rest.length === words.slice(head + 1, readTo + 1).length
|
|
1444
|
+
}
|
|
1445
|
+
// a middot-joined run of the head's own front is the key through every
|
|
1446
|
+
// piece it read ("Ghp_Abcd1234·Efgh5678·…"): the pieces are the key's, and
|
|
1447
|
+
// none of them is the text's. It still has to read as a key once whole —
|
|
1448
|
+
// seventeen characters of a word and a numeral ("file1·file2·file3",
|
|
1449
|
+
// "AKIA… · 4") is a list, not a key cut, and is read piece by piece.
|
|
1450
|
+
// The run stopped at is kept out of the mask: a word after a middot is the
|
|
1451
|
+
// text's. (A middot without a space is a cut's, and takes the last piece.)
|
|
1452
|
+
// (what the run read, its middots read as spaces: "file1 · file2 · …" is
|
|
1453
|
+
// a list of words with numerals, and together they read as a key)
|
|
1454
|
+
const fronted = headFront && run.length > 1 && midOf(head + 1) && wholeRun
|
|
1455
|
+
if (fronted) whole = readTo === j
|
|
1456
|
+
let tailP = ""
|
|
1457
|
+
if (whole && run.length > 0) {
|
|
1458
|
+
const tail = run[run.length - 1].match(/[^A-Za-z0-9_+/=\-]+$/)?.[0] ?? ""
|
|
1459
|
+
if (tail && tail !== run[run.length - 1]) {
|
|
1460
|
+
tailP = tail
|
|
1461
|
+
run[run.length - 1] = run[run.length - 1].slice(0, -tail.length)
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
// (a cut can leave a bare hyphen as a piece, "ms- 0123": dropped)
|
|
1465
|
+
// (a run of the head's own front keeps its cut's middots only when it is
|
|
1466
|
+
// one key whole: a mask over a list of pieces was "…" among the text's)
|
|
1467
|
+
// (a middot-joined run keeps its cut's middots only when it is one key
|
|
1468
|
+
// whole: a mask over a list of pieces was "\u2026" among the text's)
|
|
1469
|
+
const keepSep = !fronted && run.every((w) => /^\w+$/.test(w))
|
|
1470
|
+
const keyText = run.join("").replaceAll(MASKED, "").replace(keepSep ? /[·•∙‧・⋅]/g : /$^/g, "").replace(/^-+/, "")
|
|
1471
|
+
out.push(s.slice(from, at(head)), hf, maskToken(keyText), tailP)
|
|
1472
|
+
// the mask ends at the last piece it kept; a word after a middot that the
|
|
1473
|
+
// run read past is text, and stays as written ("… · 4 次")
|
|
1474
|
+
from = whole ? end(j) : end(head + Math.max(rest.length, 0))
|
|
1475
|
+
i = j
|
|
1476
|
+
}
|
|
1477
|
+
return out.join("") + s.slice(from)
|
|
1478
|
+
}
|
|
669
1479
|
|
|
670
1480
|
// keyShaped catches a key this plugin does not hold. tokenShaped knows only
|
|
671
1481
|
// the ms- prefix, so a server — or a proxy in front of it — that echoes
|
|
@@ -683,15 +1493,18 @@ const tokenShaped = new RegExp(String.raw`m${INV}*s${INV}*-(?:${INV}*[\x21-\x7e]
|
|
|
683
1493
|
// floor but which together are — a key written as nUJqxrpd9Bc7-GvG7J--o… or
|
|
684
1494
|
// AbCdEfGh.IjKlMnOp… (measured on the card before that arm was added: all 34
|
|
685
1495
|
// of the first, all 35 of the second). A word does not qualify — which is what
|
|
686
|
-
// keeps "
|
|
1496
|
+
// keeps "Daily check-in", a model tier or a rule_key readable on the card.
|
|
687
1497
|
const RUN = /[A-Za-z0-9]+/g
|
|
688
1498
|
// the characters a key's word can be built from; punctuation outside this set is
|
|
689
1499
|
// the frame around it ("…", a trailing full stop, a comma)
|
|
690
1500
|
const IDCH = new Set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=_.-")
|
|
691
|
-
const keyRun = (r) =>
|
|
692
|
-
(r.length >= 16 && /[a-z]/.test(r) && /[A-Z]/.test(r)) ||
|
|
1501
|
+
const keyRun = (r, letters = true) =>
|
|
1502
|
+
(letters && r.length >= 16 && /[a-z]/.test(r) && /[A-Z]/.test(r)) ||
|
|
693
1503
|
(r.length >= 32 && /\d/.test(r) && /[A-Za-z]/.test(r)) ||
|
|
694
|
-
(
|
|
1504
|
+
// (a nickname is judged without the case-mix arms — WangXiaoming2026 is a
|
|
1505
|
+
// word — but an all-caps id with a digit in it is still a key, and it is the
|
|
1506
|
+
// only shape those arms would have caught that the others do not)
|
|
1507
|
+
(r.length >= 16 && /\d/.test(r) && /[A-Z]/.test(r) && (letters || r === r.toUpperCase())) ||
|
|
695
1508
|
// 16+ of hex with both a letter and a digit in it: a digest, a session id, a
|
|
696
1509
|
// fragment of a key. Not a word (abcdefgh… carries letters that are not hex,
|
|
697
1510
|
// modelscope20261031 carries more than hex), and not a number
|
|
@@ -747,10 +1560,45 @@ const base64y = (w) => {
|
|
|
747
1560
|
// flipping pieces, is what keeps the vendor key (one flipping piece) from
|
|
748
1561
|
// being lost
|
|
749
1562
|
const alternates = (w) => (w.match(/[a-z][A-Z]/g) ?? []).length >= 2
|
|
750
|
-
|
|
1563
|
+
// A slice of a generated key can be handed back with no known key to compare
|
|
1564
|
+
// against — "no key 5gli9zadx8zg9jez for provider" from a host whose
|
|
1565
|
+
// getAuth failed — and a 12–15-character lowercase run has no case mix, no
|
|
1566
|
+
// digit by shape alone and no length floor to catch it. Its letters do not
|
|
1567
|
+
// read as a word: a run of them has few vowels (usually none), letters and
|
|
1568
|
+
// digits alternate, or digits make up much of it. A real word — "modelscope",
|
|
1569
|
+
// "workbuddy", "trae-global" — carries vowels; a long provider id with a date
|
|
1570
|
+
// in it sits under the length floor. The band is narrow on purpose: 40 and
|
|
1571
|
+
// over is the other shapes' business, and under 12 the fragments a legitimate
|
|
1572
|
+
// message carries (a version, a rule id) are short enough to stay.
|
|
1573
|
+
const VOWELS = /[aeiouy]/g
|
|
1574
|
+
const randomish = (r) => {
|
|
1575
|
+
if (r.length < 12 || r.length > 40 || !/[a-z]/.test(r)) return false
|
|
1576
|
+
if ((r.match(VOWELS) ?? []).length / r.length >= 0.2) return false
|
|
1577
|
+
const digits = (r.match(/\d/g) ?? []).length
|
|
1578
|
+
const transitions = (r.match(/[a-z][0-9]|[0-9][a-z]/g) ?? []).length
|
|
1579
|
+
return transitions >= 2 || digits >= 4
|
|
1580
|
+
}
|
|
1581
|
+
// (a run whose every piece is a word with a numeral — "file1file2file3…" — is
|
|
1582
|
+
// a list: no key's word is a word, and a middot run of them was asked at every
|
|
1583
|
+
// piece)
|
|
1584
|
+
const LISTY = /^(?:[A-Za-z]+\d+)+$/
|
|
1585
|
+
const isKeyWord = (w, letters = true) => {
|
|
751
1586
|
if (w.length < 12) return false
|
|
1587
|
+
// (a word followed by its numeral — "d8e3a6f0b5c9" — is no match: the
|
|
1588
|
+
// letters there are not a word)
|
|
1589
|
+
if (LISTY.test(w) && /[A-Za-z]{3,}/.test(w)) return false
|
|
1590
|
+
// an upper-cased vendor key is the same key, and its pieces are too short
|
|
1591
|
+
// for the run floors ("SK-ANT-API03-EYAI1PKJRF_KBWT"). A provider id in
|
|
1592
|
+
// lower case ("sk-preset-20261031") is not this arm's
|
|
1593
|
+
// (its letters and digits must alternate, as "SK-ANT-API03-EYAI1PKRF_KBWT"
|
|
1594
|
+
// does; a sentence in capitals has words without digits between them)
|
|
1595
|
+
if (VENDOR.test(w) && w === w.toUpperCase() && /[0-9]/.test(w) && !/[A-Z]{6,}/.test(w.toUpperCase().replace(/^[A-Z]+_[A-Z]+_/, ""))) return true
|
|
1596
|
+
// a run with no word in it (a slash of a key a host printed with no key in
|
|
1597
|
+
// hand) is judged by shape; the caller that knows a name from a key skips
|
|
1598
|
+
// this arm by passing `letters: false` (nameText, when a name is being shown)
|
|
1599
|
+
if (letters && /^[a-z0-9]+$/.test(w) && randomish(w)) return true
|
|
752
1600
|
if (!/[\\/]/.test(w)) {
|
|
753
|
-
if (UUID.test(w) || base64y(w) || (w.match(RUN) ?? []).some(keyRun)) return true
|
|
1601
|
+
if (UUID.test(w) || base64y(w) || (w.match(RUN) ?? []).some((r) => keyRun(r, letters))) return true
|
|
754
1602
|
// the separators out: a key cut into pieces by - _ . is still one key, and
|
|
755
1603
|
// the pieces together are long and alternate case where a name's do not.
|
|
756
1604
|
// A word with no separator is not judged here — the run test above has
|
|
@@ -763,14 +1611,85 @@ const isKeyWord = (w) => {
|
|
|
763
1611
|
// with a separator it is one key only when it does not start like a path and
|
|
764
1612
|
// carries two parts of 12+ characters mixing cases: that is an AWS secret
|
|
765
1613
|
// (wJalr…/K7MDENG/bPxRfi…), and a pair of ordinary folder names is not
|
|
766
|
-
const
|
|
767
|
-
|
|
1614
|
+
const segs = w.split(/[\\/]/).filter((seg) => seg !== "")
|
|
1615
|
+
const parts = segs.filter((seg) => seg.length >= 12 && /[a-z]/.test(seg) && /[A-Z]/.test(seg))
|
|
1616
|
+
if (!PATHY.test(w) && parts.length >= 2) return true
|
|
1617
|
+
// a key cut into pieces by slashes ("ghp_1234Ab/Cd5678EfGh/9012IjKl34/…"):
|
|
1618
|
+
// many parts, each of them key material rather than a word, and the whole
|
|
1619
|
+
// reads as a key with the slashes taken out. A path's parts carry lower-case
|
|
1620
|
+
// words ("SomeLongDirName" is a name), and a key's parts do not. A slash at
|
|
1621
|
+
// the very start is a path's own, and a key behind a frame starts at its own
|
|
1622
|
+
// front
|
|
1623
|
+
const frontAt = w.search(/ms-|sk-|gh[pousr]_|xox[abposr]-|akia|asia|aida/i)
|
|
1624
|
+
const sub = frontAt > 0 ? w.slice(frontAt) : w
|
|
1625
|
+
if (PATHY.test(sub) || /^[\\/]/.test(sub)) return false
|
|
1626
|
+
const core = sub.replace(/[^A-Za-z0-9]+$/, "")
|
|
1627
|
+
const coreSegs = core.split(/[\\/]/).filter((seg) => seg !== "")
|
|
1628
|
+
// the front's own letters are not a word ("ghp_" opens a key)
|
|
1629
|
+
const frontLen = (core.match(/^(?:ms-|sk-|gh[pousr]_|xox[abposr]-|akia|asia|aida)/i)?.[0] ?? "").length
|
|
1630
|
+
const wordy = (seg, i) => /[a-z]{2,}/.test(i === 0 ? seg.slice(frontLen) : seg)
|
|
1631
|
+
return (
|
|
1632
|
+
coreSegs.length >= 3 &&
|
|
1633
|
+
coreSegs.every((seg) => /^[A-Za-z0-9_+=.-]+$/.test(seg)) &&
|
|
1634
|
+
!coreSegs.some((seg, i) => wordy(seg, i)) &&
|
|
1635
|
+
isKeyWord(compact(core), letters)
|
|
1636
|
+
)
|
|
768
1637
|
}
|
|
769
1638
|
|
|
1639
|
+
// maskAll is the three readings a card's text gets, in order: the run reading
|
|
1640
|
+
// (maskSpaced), the harder cut (maskCut), and then word by word (maskKeys) —
|
|
1641
|
+
// whatever one of them finds is masked, and what it masked is left alone by
|
|
1642
|
+
// the next two.
|
|
1643
|
+
// maskCaps is the key written without its spaces ("AKIAIOSFODNN 7EXAMPLE"),
|
|
1644
|
+
// after the run readings: a word mixing capitals and digits, or one from a
|
|
1645
|
+
// vendor's front ("AKIA"), joined with the words after it where the join is
|
|
1646
|
+
// all capitals and digits — the shape every vendor key keeps — and long
|
|
1647
|
+
// enough to be one of them. The join only covers the pieces that are the
|
|
1648
|
+
// key's own alphabet; a word outside it ("the key AKIAIOSFODNN7 EXAM PLES
|
|
1649
|
+
// here") is where the join stops, and the joined part is what is masked.
|
|
1650
|
+
const maskCaps = (s, letters) => {
|
|
1651
|
+
// a body with no capital in it has no vendor key written without its
|
|
1652
|
+
// spaces (every one of their shapes is all capitals and digits). A name
|
|
1653
|
+
// keeps its capitals: "CHRISTOPHER LEE 2026" is all-capitals and long
|
|
1654
|
+
// enough to read as a key without its spaces, and it is a name
|
|
1655
|
+
if (!letters || s.length > 32 * 1024 || !/[A-Z]/.test(s)) return s
|
|
1656
|
+
const words = [...s.matchAll(/[\x21-\x7e]+/g)]
|
|
1657
|
+
const at = (i) => words[i].index
|
|
1658
|
+
const end = (i) => words[i].index + words[i][0].length
|
|
1659
|
+
const out = []
|
|
1660
|
+
let from = 0
|
|
1661
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
1662
|
+
const w = words[i][0]
|
|
1663
|
+
if (!VENDOR.test(w) && !(/^[A-Z0-9]{4,}$/.test(w) && /[A-Z]/.test(w))) continue
|
|
1664
|
+
let j = i
|
|
1665
|
+
let body = w
|
|
1666
|
+
while (j + 1 < words.length && s.slice(end(j), at(j + 1)).length <= 2 && /^[A-Z0-9]{2,}$/.test(words[j + 1][0]) && /^[A-Z0-9]+$/.test(body)) {
|
|
1667
|
+
j += 1
|
|
1668
|
+
body += words[j][0]
|
|
1669
|
+
}
|
|
1670
|
+
if (j === i || body.length < 16 || /[a-z]/.test(body)) continue
|
|
1671
|
+
// an upper-cased vendor shape ("AKIAIOSFODNN7 EXAMPLE") carries a piece of
|
|
1672
|
+
// an id's rhythm: a capital, a digit, short pieces. A word written out in
|
|
1673
|
+
// full ("DOCUMENTATION", "CONFIGURED") is prose in capitals
|
|
1674
|
+
const awsy = /^AKIA[0-9A-Z]{16}$/.test(body)
|
|
1675
|
+
if (!awsy && !/[A-Z][0-9]/.test(body) && !/[0-9][A-Z]/.test(body)) continue
|
|
1676
|
+
out.push(s.slice(from, at(i)), maskToken(body))
|
|
1677
|
+
from = end(j)
|
|
1678
|
+
i = j
|
|
1679
|
+
}
|
|
1680
|
+
return out.join("") + s.slice(from)
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// before maskCut: a middot-joined run of a key's shape is the key's own cut
|
|
1684
|
+
// (maskSpaced reads it whole, maskCut reads each piece alone), and maskCut
|
|
1685
|
+
// draws "·" as a mask's ellipsis when it is on the mask's own edge
|
|
1686
|
+
const maskAll = (s, letters) => maskKeys(maskSpaced(maskCut(maskSpaced(maskCaps(s, letters), letters), letters), letters), letters)
|
|
770
1687
|
// maskKeys walks the words of a piece of text: one word that is itself a key
|
|
771
1688
|
// goes, and inside a word that is not, only the key-like runs and UUIDs go.
|
|
772
1689
|
// Punctuation that cannot be part of a key stays where it was, so a card
|
|
773
|
-
// reads "(bonus ghp_16C7…8B4a)" rather than losing its brackets.
|
|
1690
|
+
// reads "(bonus ghp_16C7…8B4a)" rather than losing its brackets. A key cut by
|
|
1691
|
+
// spaces is not this walk's to find — it sees one word at a time, and four
|
|
1692
|
+
// pieces under the floors are four words kept; maskSpaced reads the run.
|
|
774
1693
|
//
|
|
775
1694
|
// The lead and tail of punctuation are found by walking in from each end, not
|
|
776
1695
|
// by an anchored regex. `word.slice(lead).match(/[^…]*$/)` backtracked from
|
|
@@ -779,8 +1698,174 @@ const isKeyWord = (w) => {
|
|
|
779
1698
|
// one match, and a server controls the ledger title it sits in. (A word of
|
|
780
1699
|
// nothing but punctuation does not show it — the first start position matches
|
|
781
1700
|
// cleanly — so the test uses that shape)
|
|
782
|
-
|
|
783
|
-
|
|
1701
|
+
// maskCut is the harder cut: a key whose front is itself cut, or whose pieces
|
|
1702
|
+
// are cut in two ("ghp _12 34A …", "sk-a nt-a pi03 -Eya …"). Read from a head
|
|
1703
|
+
// — a word carrying a vendor prefix, or folding the key's own case down to a
|
|
1704
|
+
// capital and a digit, or a run of capitals and digits alone (a key written
|
|
1705
|
+
// without its spaces: "AKIAIOSF ODNN7EXA MPLE") — the run goes on while every
|
|
1706
|
+
// piece is short and none is prose, and the join is a key or the key's pieces
|
|
1707
|
+
// keep flipping case between them. The head is where the mask begins.
|
|
1708
|
+
const conts = (w) => (/[0-9]/.test(w) || /[A-Z]/.test(w)) && !VENDOR.test(w)
|
|
1709
|
+
function maskCut(s, letters) {
|
|
1710
|
+
// a body of many KiB of one-character words costs far more reading than a
|
|
1711
|
+
// card ever shows, and a hook's whole budget is 25 seconds: past 32 KiB the
|
|
1712
|
+
// pass is skipped, and the other two readings (linear in the text) still run
|
|
1713
|
+
if (s.length > 32 * 1024) return s
|
|
1714
|
+
const words = [...s.matchAll(/[\x21-\x7e]+/g)]
|
|
1715
|
+
if (words.length < 3) return s
|
|
1716
|
+
const at = (i) => words[i].index
|
|
1717
|
+
const end = (i) => words[i].index + words[i][0].length
|
|
1718
|
+
const gap = (i) => s.slice(end(i - 1), at(i))
|
|
1719
|
+
const front = (w) => VENDOR.test(w) || /^ms-/i.test(w)
|
|
1720
|
+
// the same gaps as the run reading: a space, a line break, or a middot or a
|
|
1721
|
+
// bullet with at most one space each side
|
|
1722
|
+
const runGap = (k, i) =>
|
|
1723
|
+
/^[ \t\u00a0\u3000\r\n]+$/.test(gap(k)) && gap(k).length <= 2
|
|
1724
|
+
? true
|
|
1725
|
+
: MIDDOT_GAP.test(gap(k)) && !(caption(words[i][0]) && caption(words[k][0])) && (front(keyFrontOf(words[i][0])) || keyFrontOf(words[i][0]) !== "")
|
|
1726
|
+
// (a middot-joined list of words with numerals — "file1·file2·…", which a
|
|
1727
|
+
// log's separators write — is no key's cut, and its pieces are read on
|
|
1728
|
+
// their own: the join off the head was asked at every head, and a page of
|
|
1729
|
+
// them was read to its square)
|
|
1730
|
+
const listy = (i) => {
|
|
1731
|
+
if (!(i + 1 < words.length && MIDDOT_GAP.test(gap(i + 1)))) return false
|
|
1732
|
+
let t = words[i][0].replace(/\d+/g, "#")
|
|
1733
|
+
for (let q = i + 1; q < words.length && t.length < 64; q += 1) {
|
|
1734
|
+
if (!MIDDOT_GAP.test(gap(q))) break
|
|
1735
|
+
t += words[q][0].replace(/\d+/g, "#")
|
|
1736
|
+
}
|
|
1737
|
+
return /^(?:[A-Za-z]+#)+$/.test(t)
|
|
1738
|
+
}
|
|
1739
|
+
const out = []
|
|
1740
|
+
let from = 0
|
|
1741
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
1742
|
+
const w = words[i][0]
|
|
1743
|
+
// a key written one character per word starts at a piece of one or two
|
|
1744
|
+
// characters inside such a run (five or more one-character words); the
|
|
1745
|
+
// join decides whether the pieces are one key
|
|
1746
|
+
// (the five pieces have to be letters: a page of "1 · 1 · 1 · …" is a
|
|
1747
|
+
// run of ones, not a key typed out, and asking after every one of them
|
|
1748
|
+
// made a 32 KiB body take two seconds)
|
|
1749
|
+
const tiny = w.length <= 2 && /^[A-Za-z]+$/.test(w) && words[i + 4] !== undefined && words.slice(i, i + 5).every((x) => x[0].length <= 2)
|
|
1750
|
+
// (a stem of a front, "ms", "ghp", is a head too: the rest of the front
|
|
1751
|
+
// is in the next piece)
|
|
1752
|
+
const headKey = keyFrontOf(w)
|
|
1753
|
+
// a key's bare front ("sk-") is a head too: its body starts after it
|
|
1754
|
+
const bareFront = /^(?:sk-|gh[pousr]_|xox[abposr]-|sk_)$/i.test(w)
|
|
1755
|
+
if (!VENDOR.test(w) && !conts(headKey) && !tiny && !STEM.test(w) && !bareFront) continue
|
|
1756
|
+
if (!VENDOR.test(w) && listy(i)) continue
|
|
1757
|
+
let j = i
|
|
1758
|
+
let mixedCase = w.length >= 3 && /[a-z]/.test(w) && /[A-Z]/.test(w)
|
|
1759
|
+
let body = w
|
|
1760
|
+
let mixed = false
|
|
1761
|
+
// the reads rule below asked every past piece whether it was a word, at
|
|
1762
|
+
// every start (quadratic on a page of "1 · 1 · …"); one flag says it
|
|
1763
|
+
const readsHead = front(w)
|
|
1764
|
+
let sawWord = false
|
|
1765
|
+
for (let k = i + 1; w.length <= 64 && k < words.length && k - i <= 64; k += 1) {
|
|
1766
|
+
if (!runGap(k, i)) break
|
|
1767
|
+
// the same stop as the run reading: 20 characters of key material is
|
|
1768
|
+
// the floor, and once a cut is found the run goes on to its cap
|
|
1769
|
+
if (body.length >= 96) break
|
|
1770
|
+
const x = words[k][0]
|
|
1771
|
+
mixedCase = mixedCase || (x.length >= 3 && /[a-z]/.test(x) && /[A-Z]/.test(x))
|
|
1772
|
+
if (/^[a-z]{3,}$/.test(x)) sawWord = true
|
|
1773
|
+
const reads = readsHead || (/^[A-Z0-9]{1,2}$/.test(w) && !sawWord)
|
|
1774
|
+
// (a middle piece joined by a middot or a bullet is key material in any
|
|
1775
|
+
// case: "ms-f·orei·gn01·…" is a key cut every four characters)
|
|
1776
|
+
const middled = /^[·•∙‧・⋅]{1,2}$/.test(gap(k))
|
|
1777
|
+
if (j > i && !reads && !/[0-9A-Z]/.test(x) && !middled && !(body.length >= 24 && !/^[a-z]{1,3}$/.test(x))) break
|
|
1778
|
+
// a run with no digit and no mixed-case word can become no key (the
|
|
1779
|
+
// same reading as the run pass), and one that can is read to the cap
|
|
1780
|
+
if (j === i && body.length >= RUNCUT && !/\d/.test(body) && !mixedCase && !middled) break
|
|
1781
|
+
if (j === i && body.length >= 4 * RUNCUT) break
|
|
1782
|
+
// a key cut once into a long piece ("ghp_1234AbCd5678EfG h9012…") keeps
|
|
1783
|
+
// reading when the piece is key material: digits beside letters
|
|
1784
|
+
if (x.length > 16 && !(x.length <= 64 && /\d/.test(x) && /[A-Za-z]/.test(x)) && !VENDOR.test(x)) break
|
|
1785
|
+
// a piece of one capital and lower-case letters right after the head is
|
|
1786
|
+
// a word on its own ("sk-User"), and a word of four or more lower-case
|
|
1787
|
+
// letters inside the run is prose ("the team") — unless the head is a
|
|
1788
|
+
// run of capitals and digits, where every piece is the key's
|
|
1789
|
+
if (/^[A-Z][a-z]+$/.test(x) && k === i + 1 && !/[0-9]/.test(body)) break
|
|
1790
|
+
// a lower-case word after a run of capitals and digits is the text's
|
|
1791
|
+
// ("see AKIAIOSFODNN7EXAMPLE here"): the run is the key, whole
|
|
1792
|
+
if (/^[a-z]{3,}$/.test(x) && /^[A-Z0-9]+$/.test(body)) break
|
|
1793
|
+
// and after a run that already reads as a key ("ms-foreign-… for provider")
|
|
1794
|
+
if (/^[a-z]{4,}$/.test(x) && isKeyWord(body, letters)) break
|
|
1795
|
+
// a short word of the text's ends the run once a cut has been read, or
|
|
1796
|
+
// when the body already reads as a key ("ghp_…Qrst for provider" keeps
|
|
1797
|
+
// its "for"; the "for" of "ms-0123 for provider" too)
|
|
1798
|
+
if (/^[a-z]{1,3}$/.test(x) && (j > i || isKeyWord(body, letters))) break
|
|
1799
|
+
// the same piece-versus-prose split as the run reading: four lower-case
|
|
1800
|
+
// letters end the run unless the run starts at a key's front
|
|
1801
|
+
if (/^[a-z]{4,}$/.test(x) && body.length >= 4 && !/[0-9]/.test(body) && !/^[A-Z0-9]+$/.test(body) && (x.length > 4 || !(VENDOR.test(w) || /^ms-/i.test(w)))) break
|
|
1802
|
+
body += x
|
|
1803
|
+
if (body.length < 20 || body.includes(MASKED)) continue
|
|
1804
|
+
const whole = body.length >= 16 && (body.match(/[A-Za-z0-9]+/g) ?? []).some((r) => keyRun(r, letters)) // every keyRun arm floors at 16
|
|
1805
|
+
const flips = body.match(/[a-z][A-Z]/g) ?? []
|
|
1806
|
+
// (only asked once both is on the table; most of a run's words carry one case)
|
|
1807
|
+
let pairMix = -1
|
|
1808
|
+
let shortMix = false
|
|
1809
|
+
if (whole || flips.length >= 2) {
|
|
1810
|
+
const run = words.slice(i, k + 1)
|
|
1811
|
+
// a caption ("Qwen3-Max") is mixed case too, and a list of them is not
|
|
1812
|
+
// a key: a key's words flip case inside themselves ("i1pkJrF")
|
|
1813
|
+
pairMix = run.filter((y) => /[a-z]/.test(y[0]) && /[A-Z]/.test(y[0]) && !/^[A-Z][a-z]{2,}/.test(y[0])).length
|
|
1814
|
+
shortMix = pairMix > 0 && run.some((y) => y[0].length <= 12 && /[a-z]/.test(y[0]) && /[A-Z]/.test(y[0]))
|
|
1815
|
+
}
|
|
1816
|
+
if (whole || flips.length >= 2 || (pairMix >= 2 && shortMix)) {
|
|
1817
|
+
j = k
|
|
1818
|
+
mixed = whole || flips.length >= 2
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
if (j === i || !mixed) continue
|
|
1822
|
+
const run = words.slice(i, j + 1)
|
|
1823
|
+
// a run of words that are each prose-shaped ("Rebecca Deborah Bradford")
|
|
1824
|
+
// is two runs of words, not a key
|
|
1825
|
+
if (run.every((x) => /^[a-z]{3,}$/i.test(x[0]))) continue
|
|
1826
|
+
// the mask is the key's characters alone; the separators it was cut by
|
|
1827
|
+
// stay inside it, as in the run reading
|
|
1828
|
+
out.push(s.slice(from, at(i)), maskToken(run.map((x) => x[0]).join("").replace(/[·•∙‧・⋅]/g, "")))
|
|
1829
|
+
from = end(j)
|
|
1830
|
+
i = j
|
|
1831
|
+
}
|
|
1832
|
+
return out.join("") + s.slice(from)
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
const maskKeys = (s, letters = true) => {
|
|
1836
|
+
return s.replace(/[^\s]+/g, (word) => {
|
|
1837
|
+
// The word in the other spellings a key is printed in — full-width
|
|
1838
|
+
// characters, mostly. Nothing below can read it: there is no ASCII body
|
|
1839
|
+
// and no ASCII path prefix, so every pass saw ordinary text. Measured, a
|
|
1840
|
+
// host error carrying "no key ms-… for provider" printed the whole
|
|
1841
|
+
// token, and NFKC put it back together. The folded copy is what the shape
|
|
1842
|
+
// tests judge, and the word as written is what is masked.
|
|
1843
|
+
const folded = word.normalize("NFKC").replace(/\uFF0D/g, "-")
|
|
1844
|
+
// (the word as written is what is masked, and the folded copy is what is
|
|
1845
|
+
// judged \u2014 but only the key's own span of it: a word's prose is the
|
|
1846
|
+
// word's, and masking the whole took four ideographs of it with the key
|
|
1847
|
+
// ("\u4F59\u989D\u67E5\u8BE2\u5931\u8D25\uFF1A\uFF4D\uFF53\uFF0D\u2026" came out "\u4F59\u989D\u67E5\u8BE2\u20263456"). A fold that keeps
|
|
1848
|
+
// the word's length maps back character for character; one that does not
|
|
1849
|
+
// (a ligature, a squared character) has no span to name, and the word is
|
|
1850
|
+
// masked whole as before)
|
|
1851
|
+
if (folded !== word) {
|
|
1852
|
+
const bodyOf = (w) => {
|
|
1853
|
+
let x = 0
|
|
1854
|
+
while (x < w.length && !IDCH.has(w[x])) x += 1
|
|
1855
|
+
let y = w.length
|
|
1856
|
+
while (y > x && !IDCH.has(w[y - 1])) y -= 1
|
|
1857
|
+
return [x, y]
|
|
1858
|
+
}
|
|
1859
|
+
const readsAsKey = (w) => isKeyWord(w, letters) || (w.match(RUN) ?? []).some((r) => keyRun(r, letters)) || UUID.test(w)
|
|
1860
|
+
if (folded.length === word.length) {
|
|
1861
|
+
const [fa, fb] = bodyOf(folded)
|
|
1862
|
+
if (fb > fa && readsAsKey(folded.slice(fa, fb))) {
|
|
1863
|
+
return word.slice(0, fa) + maskToken(word.slice(fa, fb)) + word.slice(fb)
|
|
1864
|
+
}
|
|
1865
|
+
} else if (readsAsKey(folded)) {
|
|
1866
|
+
return maskToken(word)
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
784
1869
|
let a = 0
|
|
785
1870
|
while (a < word.length && !IDCH.has(word[a])) a += 1
|
|
786
1871
|
let b = word.length
|
|
@@ -793,11 +1878,21 @@ const maskKeys = (s) =>
|
|
|
793
1878
|
// begins with two backslashes, and \ is not in the identity set — stripped
|
|
794
1879
|
// as "lead", the rest looked like a relative path with two long camelCase
|
|
795
1880
|
// folders and was masked whole (the same test on the word sees PATHY)
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1881
|
+
// a word with a frame round it ("NickName=[ghp_…") is judged by what the
|
|
1882
|
+
// frame encloses too: the key may start after a bracket or a "="
|
|
1883
|
+
const start = body.search(/[A-Za-z0-9]/)
|
|
1884
|
+
// (a leading slash is a path's frame, not a key's)
|
|
1885
|
+
// a frame is punctuation or path-like ("NickName=[…", "余额查询失败:…"); a
|
|
1886
|
+
// run of capitals and digits before the key is the key's own front
|
|
1887
|
+
// ("SK-ANT-API03-EYAI1PKJRF_KBWT" behind a colon), and is judged whole
|
|
1888
|
+
const encl = start > 0 && body.length <= 512 && !/[\\/]/.test(body.slice(0, start)) && !/[A-Za-z0-9]{2,}/.test(body.slice(0, start)) ? body.slice(start) : ""
|
|
1889
|
+
const masked =
|
|
1890
|
+
(PATHY.test(word) ? false : isKeyWord(body, letters)) || isKeyWord(word, letters) || (encl !== "" && isKeyWord(encl, letters))
|
|
1891
|
+
? maskToken(body)
|
|
1892
|
+
: body.replace(RUN, (r) => (keyRun(r, letters) ? maskToken(r) : r)).replace(UUID_ANY, (m) => maskToken(m))
|
|
799
1893
|
return lead + masked + tail
|
|
800
1894
|
})
|
|
1895
|
+
}
|
|
801
1896
|
|
|
802
1897
|
// cardText is what a host error becomes before it reaches the account card or
|
|
803
1898
|
// the terminal. The host's message is not our text: magpie's own CLI scrubs
|
|
@@ -822,7 +1917,20 @@ const maskKeys = (s) =>
|
|
|
822
1917
|
const cardText = (value, token, max = 200) => {
|
|
823
1918
|
let s = String(value ?? "").replace(GLUE, "")
|
|
824
1919
|
if (typeof token === "string" && token) s = s.split(token).join(maskToken(token))
|
|
825
|
-
|
|
1920
|
+
s = maskEchoes(s, token).replace(tokenShaped, maskShaped)
|
|
1921
|
+
return text(maskAll(s, true), max)
|
|
1922
|
+
}
|
|
1923
|
+
// The account's own name is text the user picked, not a server's message: a
|
|
1924
|
+
// long camelCase name ("MisonLiangWithALongName") is key-shaped to maskKeys and
|
|
1925
|
+
// was shown as "Miso…Name" on the card. Names still go through the passes that
|
|
1926
|
+
// matter — the key this process holds, the ms- shape, other vendors' key
|
|
1927
|
+
// shapes, a bare digest — only the "16+ characters with a case mix" arm is off.
|
|
1928
|
+
const nameText = (value, token, max = 200) => {
|
|
1929
|
+
let s = String(value ?? "").replace(GLUE, "")
|
|
1930
|
+
if (typeof token === "string" && token) s = s.split(token).join(maskToken(token))
|
|
1931
|
+
// with no token in hand the mask's shape pass is the only guard there is,
|
|
1932
|
+
// so the case-mix arm has to stay on
|
|
1933
|
+
return text(maskAll(maskEchoes(s, token).replace(tokenShaped, maskShaped), !token), max)
|
|
826
1934
|
}
|
|
827
1935
|
|
|
828
1936
|
// hostText is cardText for the two exits where there is no token to split on:
|
|
@@ -830,7 +1938,7 @@ const cardText = (value, token, max = 200) => {
|
|
|
830
1938
|
// The host's message can still carry one — magpie's own wording for a missing
|
|
831
1939
|
// key is "no key <key> for provider" — and these two lines would print it
|
|
832
1940
|
// whole. With no key in hand the shape is all there is, which cardText does.
|
|
833
|
-
const hostText = (value) =>
|
|
1941
|
+
const hostText = (value) => nameText(value, "")
|
|
834
1942
|
|
|
835
1943
|
// unusableName is a nickname that must not be written back: empty, the token
|
|
836
1944
|
// itself or a piece of it (the server would be handed it twice), a key the
|
|
@@ -844,60 +1952,354 @@ const hostText = (value) => cardText(value, "")
|
|
|
844
1952
|
// key-shaped to the card as well, and it is not a key — a generated key nearly
|
|
845
1953
|
// always carries a digit or a base64 symbol, and a name nearly never does, so
|
|
846
1954
|
// nameIsKey asks for one of those beside the card's verdict.
|
|
847
|
-
//
|
|
848
|
-
//
|
|
849
|
-
//
|
|
850
|
-
//
|
|
851
|
-
|
|
1955
|
+
// tightName is a name with the characters an echo may insert taken out. A
|
|
1956
|
+
// server that echoed the token into a nickname can space it out just as a
|
|
1957
|
+
// message can, and "ms-01234567 89abcdef…" is no longer a substring of the
|
|
1958
|
+
// token — but the tight form is.
|
|
1959
|
+
const tightName = (name) => name.replace(/[\s\p{Cc}\p{Cf}]+/gu, "")
|
|
1960
|
+
// flatName keeps only the characters a token is made of. Any other character
|
|
1961
|
+
// in a name — a space, a comma, a bracket — is something an echo inserted, and
|
|
1962
|
+
// the token's own characters survive in order.
|
|
1963
|
+
const flatName = (name) => name.replace(/[^A-Za-z0-9]/g, "")
|
|
1964
|
+
// insideToken: the name is in the token, or its tight form is, or its flat
|
|
1965
|
+
// form is a run of the token's flat form. Either way every character of it is
|
|
1966
|
+
// already in plugin-auth.json, so writing it back leaks nothing new — which is
|
|
1967
|
+
// what makes nameIsKey skip it. A token that holds the name is the proof; it
|
|
1968
|
+
// does not matter how long the name is.
|
|
1969
|
+
const insideToken = (name, token) => {
|
|
1970
|
+
if (typeof token !== "string" || token === "") return false
|
|
1971
|
+
if (token.includes(name) || token.includes(tightName(name))) return true
|
|
1972
|
+
const flat = flatName(name)
|
|
1973
|
+
const t = fold(token)
|
|
1974
|
+
if (flat.length >= 20 && t.includes(flat)) return true
|
|
1975
|
+
// the same answer for the case and compatibility folds: an upper-cased or
|
|
1976
|
+
// full-width name is the same characters the sign-in already holds
|
|
1977
|
+
const n = fold(name)
|
|
1978
|
+
return n.length >= 20 && t.includes(n)
|
|
1979
|
+
}
|
|
1980
|
+
// echoesToken: the name is the token being handed back — the token itself, or
|
|
1981
|
+
// a piece long enough to be an echo. The threshold is the mask's own (MIN_ECHO,
|
|
1982
|
+
// 10) rather than the 20 it used to be: a nickname of 10–19 characters is not
|
|
1983
|
+
// a name a person picks, and the card's `user` line printed it whole while the
|
|
1984
|
+
// mask said nothing about pieces that short. A name of a few characters that
|
|
1985
|
+
// happens to sit inside a token ("ms", "ab") is still a name.
|
|
1986
|
+
const echoesToken = (name, token) => {
|
|
1987
|
+
if (typeof token !== "string" || !token) return false
|
|
1988
|
+
if (name.includes(token)) return true
|
|
1989
|
+
const n = fold(name)
|
|
1990
|
+
const t = fold(token)
|
|
1991
|
+
if (!n || !t) return false
|
|
1992
|
+
if (n.includes(t)) return true
|
|
1993
|
+
return insideToken(name, token) && tightName(name).length >= MIN_ECHO
|
|
1994
|
+
}
|
|
1995
|
+
// runsThrough: the name's folded characters carry MIN_ECHO of the token's, in
|
|
1996
|
+
// order, even when the echo spaced them out with characters the token does not
|
|
1997
|
+
// hold. maskEchoes stops at a filler ("ms-0123abcd4567x 89ef…" is pieces each
|
|
1998
|
+
// under MIN_ECHO), but the pieces still spell the key once the filler is
|
|
1999
|
+
// dropped, and a nickname is written to plugin-auth.json as a second copy of
|
|
2000
|
+
// it. A name that shares a dozen characters of a 32-character key in order is
|
|
2001
|
+
// not a name a person picks; a short name ("ms", "Mison") cannot reach the
|
|
2002
|
+
// floor.
|
|
2003
|
+
//
|
|
2004
|
+
// The run is measured as the longest run the name shares with the key, not by
|
|
2005
|
+
// one greedy pass: a stray character at the name's start or end used to take
|
|
2006
|
+
// the key's first or last position on that pass and hide the body behind it
|
|
2007
|
+
// ("f 01234567x 89abcde0x …" either way round). Ordinary nicknames share 6 or
|
|
2008
|
+
// 7 characters in a row with a realistic key; a key body spelled out shares
|
|
2009
|
+
// 30+.
|
|
2010
|
+
//
|
|
2011
|
+
// A name can reach a run by luck too: a nickname that is a string of digits
|
|
2012
|
+
// is a run of any key holding them in order ("1234567890" is nine characters
|
|
2013
|
+
// of most of them). A run is taken for the key when it is most of the name (a
|
|
2014
|
+
// dozen characters and half of it, or twenty of it however short the name is),
|
|
2015
|
+
// or when the name's whole folded self is a run of the key that a person's
|
|
2016
|
+
// name could not be — anything but digits, spaces and hyphens ("bbbb 2222"
|
|
2017
|
+
// against a key with those characters in a row).
|
|
2018
|
+
const MANY = MIN_ECHO + 2
|
|
2019
|
+
const BACK_MANY = MANY + MIN_ECHO
|
|
2020
|
+
// The DP below is n × t cells. Neither side is bounded by this plugin: the
|
|
2021
|
+
// name comes from the server's answer (a 256 KiB body is allowed) and the key
|
|
2022
|
+
// from the host's sign-in. Measured before this cap existed: a 128 KiB name
|
|
2023
|
+
// against a 32 Ki-character key spent 19 s of CPU inside a hook whose whole
|
|
2024
|
+
// budget is 25 s, and an ordinary 128 KiB nickname against a realistic key
|
|
2025
|
+
// spent ~1.8 s. No answer needs that: the write-back is cut to 40 characters,
|
|
2026
|
+
// so a longer name is not a name a person picks, and refusing it is the safe
|
|
2027
|
+
// direction. The cell cap keeps the work under ~60 ms at the measured rate and
|
|
2028
|
+
// refuses the name instead of spending the budget on it.
|
|
2029
|
+
const NAME_LCS_MAX = 512
|
|
2030
|
+
const LCS_CELLS = 2_000_000
|
|
2031
|
+
function longestRun(n, t) {
|
|
2032
|
+
let prev = new Int32Array(t.length + 1)
|
|
2033
|
+
let cur = new Int32Array(t.length + 1)
|
|
2034
|
+
for (let i = 1; i <= n.length; i += 1) {
|
|
2035
|
+
for (let j = 1; j <= t.length; j += 1) {
|
|
2036
|
+
cur[j] = n[i - 1] === t[j - 1] ? prev[j - 1] + 1 : Math.max(prev[j], cur[j - 1])
|
|
2037
|
+
}
|
|
2038
|
+
const swap = prev
|
|
2039
|
+
prev = cur
|
|
2040
|
+
cur = swap
|
|
2041
|
+
cur.fill(0)
|
|
2042
|
+
}
|
|
2043
|
+
return prev[t.length]
|
|
2044
|
+
}
|
|
2045
|
+
const runsThrough = (name, token) => {
|
|
2046
|
+
const n = fold(name)
|
|
2047
|
+
const t = fold(token)
|
|
2048
|
+
if (n.length < MIN_ECHO || t.length < MIN_ECHO) return false
|
|
2049
|
+
if (insideToken(name, token) && n.length < 20) return false
|
|
2050
|
+
// too long a name to be a name, or too much DP work to spend a hook's budget
|
|
2051
|
+
// on: treat it as unusable, which is what the write-back does with any name
|
|
2052
|
+
// it cannot vouch for. The card still shows its usual mask for the shape.
|
|
2053
|
+
if (n.length > NAME_LCS_MAX || t.length > NAME_LCS_MAX || n.length * t.length > LCS_CELLS) return true
|
|
2054
|
+
const run = longestRun(n, t)
|
|
2055
|
+
if (run >= MANY && (run * 2 >= n.length || run >= BACK_MANY)) return true
|
|
2056
|
+
// a nickname that is almost nothing but the key's characters, in the key's
|
|
2057
|
+
// order, is the key written with something between its characters. A string
|
|
2058
|
+
// of digits is not: those are a run of any key that holds them, and a name
|
|
2059
|
+
// made of them says nothing about which.
|
|
2060
|
+
return run >= MIN_ECHO && run * 3 >= n.length * 2 && !/^[\d\s-]+$/.test(tightName(name))
|
|
2061
|
+
}
|
|
852
2062
|
const nameIsKey = (name, token) =>
|
|
853
|
-
!(
|
|
854
|
-
name.split(/\s+/).some(
|
|
2063
|
+
!insideToken(name, token) &&
|
|
2064
|
+
name.split(/\s+/).some(
|
|
2065
|
+
(w) =>
|
|
2066
|
+
isKeyWord(w) &&
|
|
2067
|
+
((w.match(/\d/g) ?? []).length >= 6 || /[+/=]/.test(w) || /^[0-9a-f]{16,}$/i.test(w)),
|
|
2068
|
+
)
|
|
855
2069
|
const unusableName = (name, token) =>
|
|
856
2070
|
!name ||
|
|
857
2071
|
name === token ||
|
|
858
|
-
(
|
|
2072
|
+
echoesToken(name, token) ||
|
|
2073
|
+
runsThrough(name, token) ||
|
|
859
2074
|
nameIsKey(name, token) ||
|
|
2075
|
+
nameShape(name) ||
|
|
860
2076
|
["__proto__", "constructor", "prototype"].includes(name)
|
|
2077
|
+
// nameShape: the name is a credential of a shape this sign-in does not hold —
|
|
2078
|
+
// another account's key, or this one's written in another spelling. The tests
|
|
2079
|
+
// above look for the token in hand; a server echoing a *different* key
|
|
2080
|
+
// ("ms-…" in full width, a bare 32-character body) matched none of them and
|
|
2081
|
+
// was written back as accountId. Only credential shapes count: an ordinary
|
|
2082
|
+
// name, camelCase or carrying a year (WangXiaoming2026), is a name.
|
|
2083
|
+
//
|
|
2084
|
+
// The ms- arm is the shape the card's own mask uses (tokenShaped), not a
|
|
2085
|
+
// narrower list of characters: a nickname written as ms-foreign_key… carries an
|
|
2086
|
+
// underscore, the card masks it, and the narrow arm passed it through to be
|
|
2087
|
+
// stored as the account's name — the one field the card's mask does not reach.
|
|
2088
|
+
// Every character a header can carry is a key's character, so the question is
|
|
2089
|
+
// the shape, not the spelling.
|
|
2090
|
+
const nameShape = (name) => {
|
|
2091
|
+
// the same invisible-character join the card runs before its shape test:
|
|
2092
|
+
// "ms-foreign_key…" is the same key to the card, and a nameShape that
|
|
2093
|
+
// read it as "ms-foreign_key…" (with a zero-width space in it) refused it by
|
|
2094
|
+
// luck rather than by rule. The space fold after it is for a name written
|
|
2095
|
+
// with spaces between the pieces — a shape that survived the key comparison
|
|
2096
|
+
// in another spelling.
|
|
2097
|
+
const u = name.replace(GLUE, "").normalize("NFKC")
|
|
2098
|
+
const t = u.toLowerCase().replace(/\s+/g, "")
|
|
2099
|
+
// the ms- arm asks the card's own question of the body (keyBody): a word
|
|
2100
|
+
// after the front is a name ("ms-interviewing" is one, and refusing it left
|
|
2101
|
+
// the account with no name at all). A name that is a key of another vendor —
|
|
2102
|
+
// sk-ant-api03-Eyai1pkJrF_Kbwt has three digits and satisfied none of
|
|
2103
|
+
// nameIsKey's extra requirements — is the shape the vendor's own prefix
|
|
2104
|
+
// marks, and a vendor key's body is what the shape tests already accept.
|
|
2105
|
+
// the card's own two questions of an ms- body: does it begin like a key
|
|
2106
|
+
// (tokenShaped), and does the run after the front read as one (keyBody),
|
|
2107
|
+
// both asked of the name as written — spaces and all. Asking them of the
|
|
2108
|
+
// space-folded name let a version number join a short word into a body, and
|
|
2109
|
+
// "ms-cli 2026.11.9" and "ms-office 2016" — names the card shows untouched
|
|
2110
|
+
// — were refused their write-back. A name with pieces spaced out is still
|
|
2111
|
+
// refused by the flat forms above when it is this token's
|
|
2112
|
+
return /^ms-/.test(t)
|
|
2113
|
+
? [...u.matchAll(tokenShaped)].some((x) => keyBody.test(x[0]))
|
|
2114
|
+
: VENDOR.test(t) ||
|
|
2115
|
+
/^[0-9a-f]{16,}$/.test(t) ||
|
|
2116
|
+
UUID.test(t)
|
|
2117
|
+
}
|
|
861
2118
|
|
|
862
|
-
|
|
2119
|
+
// withStreak puts the streak on an answer only when it was read to the end.
|
|
2120
|
+
// An explicit `streak: undefined` is not "said nothing": the host reads the
|
|
2121
|
+
// field with num(), and undefined becomes 0 — a card showing "0 天" for a run
|
|
2122
|
+
// the plugin could not see the end of. The key is left out instead, and the
|
|
2123
|
+
// answer is told about an expiry the walk met on the way (`marks`), which the
|
|
2124
|
+
// walk records because a claim that already landed still has to reach the
|
|
2125
|
+
// host as "the token was refused".
|
|
2126
|
+
const withStreak = (answer, streak, marks) =>
|
|
2127
|
+
({
|
|
2128
|
+
...answer,
|
|
2129
|
+
...(streak === undefined ? {} : { streak }),
|
|
2130
|
+
...(marks?.expired ? { signIn: "expired" } : {}),
|
|
2131
|
+
})
|
|
2132
|
+
// WRITE_WAIT_MS caps how long a hook waits for the host's write-back. The
|
|
2133
|
+
// lock it takes can block for ~20s (host.js's lockAuth, when another process
|
|
2134
|
+
// holds plugin-auth.json), and magpie runs every provider's check-ins under
|
|
2135
|
+
// one two-minute budget — one contended account must not eat a third of it
|
|
2136
|
+
// for a field the card can live without.
|
|
2137
|
+
const WRITE_WAIT_MS = 3000
|
|
2138
|
+
|
|
2139
|
+
// BOUNDED is what bounded answers when the timer won: the promise's own value
|
|
2140
|
+
// could be undefined, and "the host never answered" must not read as "the host
|
|
2141
|
+
// said nothing".
|
|
2142
|
+
const BOUNDED = Symbol("bounded")
|
|
2143
|
+
const bounded = (p, ms) => {
|
|
2144
|
+
let timer
|
|
2145
|
+
const alarm = new Promise((resolve) => {
|
|
2146
|
+
timer = setTimeout(() => resolve(BOUNDED), Math.max(0, ms))
|
|
2147
|
+
if (timer && typeof timer.unref === "function") timer.unref()
|
|
2148
|
+
})
|
|
2149
|
+
return Promise.race([p, alarm]).finally(() => clearTimeout(timer))
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// waitFor resolves when `p` settles or after `ms`, whichever comes first; the
|
|
2153
|
+
// loser is left running. The timer is unref'd so it never holds a process open.
|
|
2154
|
+
const waitFor = (p, ms) => {
|
|
2155
|
+
let timer
|
|
2156
|
+
const alarm = new Promise((resolve) => {
|
|
2157
|
+
timer = setTimeout(resolve, ms)
|
|
2158
|
+
if (timer && typeof timer.unref === "function") timer.unref()
|
|
2159
|
+
})
|
|
2160
|
+
return Promise.race([p, alarm]).finally(() => clearTimeout(timer))
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
// rememberAccountFields tells magpie what the plugin learned about this
|
|
2164
|
+
// sign-in: the account's own name (so the card reads "Mison" instead of
|
|
2165
|
+
// "API key …a1b2"), and — for an account on the international site — the site
|
|
2166
|
+
// itself, so a restarted host does not have to hand the token to the domestic
|
|
2167
|
+
// site for a 401 first. A site the probe has since found to be wrong is
|
|
2168
|
+
// removed. The sign-in is written as it already is — the same key, no second
|
|
2169
|
+
// copy of the token — with those fields beside it; what is already there is
|
|
2170
|
+
// left alone. Everything goes in one write: auth.set takes plugin-auth.json's
|
|
2171
|
+
// lock, and two writes would take it twice.
|
|
2172
|
+
async function rememberAccountFields(client, token, learned, auth, dir, budgetEnd = Infinity, getAuth) {
|
|
863
2173
|
// "can write" is a function, not a truthy value: `{ auth: { set: 1 } }` is a
|
|
864
2174
|
// client that cannot write, and `!client?.auth?.set` let it through to call
|
|
865
|
-
// a number — the TypeError is caught below, but it logs "account
|
|
2175
|
+
// a number — the TypeError is caught below, but it logs "account facts not
|
|
866
2176
|
// written back" on every check-in, a failure report for a client that never
|
|
867
2177
|
// had a chance to succeed.
|
|
868
|
-
if (
|
|
869
|
-
|
|
2178
|
+
if (typeof client?.auth?.set !== "function") return
|
|
2179
|
+
// auth.set replaces the whole sign-in, and the copy the hook read when it
|
|
2180
|
+
// started can be a minute old by the time the requests are done. Written on
|
|
2181
|
+
// top of that copy, an edit made meanwhile is undone: a new key pasted in
|
|
2182
|
+
// while the hook ran was overwritten with the old one, and a field added
|
|
2183
|
+
// was dropped. The host only refuses the write when the account is gone
|
|
2184
|
+
// entirely (host.js's `had && !(key in all)`), so the read is repeated here
|
|
2185
|
+
// and the write lands on the freshest copy. A sign-in that no longer holds
|
|
2186
|
+
// this key is not this key's account any more: the facts learned here are
|
|
2187
|
+
// about a token it no longer carries, so nothing is written.
|
|
2188
|
+
let base = auth
|
|
2189
|
+
if (typeof getAuth === "function") {
|
|
2190
|
+
try {
|
|
2191
|
+
// The read is host I/O like the write: it takes the same store lock and
|
|
2192
|
+
// can block for ~20s. Capped by the same budget, and an unreadable store
|
|
2193
|
+
// means no write at all — falling back to the copy from the start of the
|
|
2194
|
+
// hook is exactly the stale base that overwrote a key pasted meanwhile.
|
|
2195
|
+
const cap = Math.max(0, Math.min(WRITE_WAIT_MS, budgetEnd - Date.now()))
|
|
2196
|
+
// Nothing left of the hook's budget: the read and the write both take
|
|
2197
|
+
// the host's store lock, and starting either at zero would spend the
|
|
2198
|
+
// time the answer was supposed to fit in. The fields are best-effort.
|
|
2199
|
+
if (cap <= 0) return
|
|
2200
|
+
const now = await waitFor(Promise.resolve().then(getAuth), cap)
|
|
2201
|
+
// what the host hands over is not always a plain object (a callable
|
|
2202
|
+
// carrying the fields is a sign-in too); the key is what says it is
|
|
2203
|
+
// still this account's entry, and the spread below skips a non-object
|
|
2204
|
+
if (now === undefined || tokenOf(now) !== token) return
|
|
2205
|
+
base = now
|
|
2206
|
+
} catch {
|
|
2207
|
+
return
|
|
2208
|
+
}
|
|
2209
|
+
}
|
|
2210
|
+
const name = learned?.name ?? ""
|
|
2211
|
+
// Only the international site is worth keeping: a probe tries the domestic
|
|
2212
|
+
// one first, so recording it saves no request and costs a locked write.
|
|
2213
|
+
const site = learned?.profile?.site === INTL.site ? INTL.site : ""
|
|
2214
|
+
const wantSite = !!site && base?.site !== site
|
|
2215
|
+
// A sign-in that names a site but the probe found the account on the
|
|
2216
|
+
// domestic one is stale — the key was replaced under it. Left alone, every
|
|
2217
|
+
// restart would pay a 401 against the site it names, so it is dropped. The
|
|
2218
|
+
// probe has to have actually found the domestic site for this to mean
|
|
2219
|
+
// anything: after a failed read, `learned.profile` is null, the site was
|
|
2220
|
+
// simply not reached, and clearing it would be an own-goal on a blip.
|
|
2221
|
+
const wantClear =
|
|
2222
|
+
!site &&
|
|
2223
|
+
learned?.probeDone &&
|
|
2224
|
+
typeof base?.site === "string" &&
|
|
2225
|
+
base.site !== ""
|
|
870
2226
|
const at = named.get(token)
|
|
871
|
-
|
|
2227
|
+
const cooling = at !== undefined && Date.now() - at < NAME_AGAIN_MS
|
|
872
2228
|
// an account the user has named keeps that name: writing the nickname over
|
|
873
2229
|
// it would re-key their alias to a label the account no longer has. A
|
|
874
2230
|
// providers.json that can't be read says the same thing — don't write over
|
|
875
2231
|
// a state we can't see.
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
try {
|
|
881
|
-
// auth.set replaces the whole sign-in, so what the host handed us is the
|
|
882
|
-
// base: a field we do not know about is kept, not dropped
|
|
883
|
-
const body = { ...(auth && typeof auth === "object" ? auth : {}), type: "api", key: token, accountId: name }
|
|
884
|
-
await client.auth.set({ path: { id: profile.id }, body })
|
|
885
|
-
} catch (e) {
|
|
886
|
-
// the card keeps the key's tail; nothing else depends on this. Put the
|
|
887
|
-
// token back so the next ask tries the write again, and let the log call
|
|
888
|
-
// be truly best-effort: a host whose log is synchronous returns undefined
|
|
889
|
-
// where a promise is expected, and that must not throw out of here.
|
|
2232
|
+
const wantName =
|
|
2233
|
+
!!name && base?.accountId !== name && !cooling && aliasOf(ID, dir, labelOf(base, token)) === ""
|
|
2234
|
+
if (!wantName && !wantSite && !wantClear) return
|
|
2235
|
+
if (wantName) {
|
|
890
2236
|
named.delete(token)
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
2237
|
+
named.set(token, Date.now())
|
|
2238
|
+
while (named.size > NAMES_MAX) named.delete(named.keys().next().value)
|
|
2239
|
+
}
|
|
2240
|
+
try {
|
|
2241
|
+
// auth.set replaces the whole sign-in, so the freshest copy the host
|
|
2242
|
+
// handed us is the base: a field we do not know about is kept, not dropped
|
|
2243
|
+
const body = {
|
|
2244
|
+
...(base && typeof base === "object" ? base : {}),
|
|
2245
|
+
type: "api",
|
|
2246
|
+
key: token,
|
|
2247
|
+
...(wantName ? { accountId: name } : {}),
|
|
2248
|
+
...(wantSite ? { site } : {}),
|
|
2249
|
+
}
|
|
2250
|
+
if (wantClear) delete body.site
|
|
2251
|
+
// auth.set takes plugin-auth.json's lock and, when another process holds
|
|
2252
|
+
// it, can block for up to ~20s (host.js's lockAuth). The answer is worth
|
|
2253
|
+
// more than this best-effort write, and a check-in round serves every
|
|
2254
|
+
// provider under one two-minute budget, so the wait is capped: a write
|
|
2255
|
+
// still waiting past the cap finishes behind the hook instead of holding
|
|
2256
|
+
// it. A rejection that comes after the cap is handled where it lands —
|
|
2257
|
+
// reported and retried on the next ask — so a locked store does not cost
|
|
2258
|
+
// an hour of the nickname's cooldown with no sign that anything failed.
|
|
2259
|
+
const writing = Promise.resolve(client.auth.set({ path: { id: ID }, body }))
|
|
2260
|
+
// the handler is attached before the race so a rejection that lands after
|
|
2261
|
+
// the cap still finds one; both sides of the cap run the same report
|
|
2262
|
+
// one report per write: the failure reaches both the attached handler and
|
|
2263
|
+
// the race, and a doubled line would read as two failed writes
|
|
2264
|
+
let reported = false
|
|
2265
|
+
const failed = (e) => {
|
|
2266
|
+
if (reported) return
|
|
2267
|
+
reported = true
|
|
2268
|
+
onWriteFailed(e, client, token, wantName)
|
|
2269
|
+
}
|
|
2270
|
+
// The cap is the hook's own budget end as well as WRITE_WAIT_MS: a hook
|
|
2271
|
+
// that spent its reads right up to the budget must not spend three more
|
|
2272
|
+
// seconds waiting for a lock, or the whole hook outlives the number the
|
|
2273
|
+
// README promises. A write still pending then finishes behind the hook.
|
|
2274
|
+
const cap = Math.max(0, Math.min(WRITE_WAIT_MS, budgetEnd - Date.now()))
|
|
2275
|
+
writing.catch(failed)
|
|
894
2276
|
try {
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
// a host whose log throws synchronously must not turn a successful
|
|
899
|
-
// check-in into a failed one, nor a usage read into a rejection
|
|
2277
|
+
await waitFor(writing, cap)
|
|
2278
|
+
} catch (e) {
|
|
2279
|
+
failed(e)
|
|
900
2280
|
}
|
|
2281
|
+
} catch (e) {
|
|
2282
|
+
// a synchronous throw from the host's own set(): same report, same retry
|
|
2283
|
+
onWriteFailed(e, client, token, wantName)
|
|
2284
|
+
}
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// onWriteFailed is the one report for every failed write, whether it failed at
|
|
2288
|
+
// once or after the wait was capped: the name goes back so the next ask tries
|
|
2289
|
+
// again, and the reason is logged without the token in it. Best-effort — a
|
|
2290
|
+
// host whose log is synchronous returns undefined here, and that must not
|
|
2291
|
+
// throw out of the caller.
|
|
2292
|
+
function onWriteFailed(e, client, token, wantName) {
|
|
2293
|
+
if (wantName) named.delete(token)
|
|
2294
|
+
// the message can carry whatever the host put in it; scrub the token out of
|
|
2295
|
+
// it before it reaches magpie's log
|
|
2296
|
+
const why = cardText(whyOf(e), token)
|
|
2297
|
+
try {
|
|
2298
|
+
const r = client?.app?.log?.({ body: { level: "warn", service: ID, message: `account facts not written back: ${why}` } })
|
|
2299
|
+
if (r && typeof r.catch === "function") r.catch(() => {})
|
|
2300
|
+
} catch {
|
|
2301
|
+
// a host whose log throws synchronously must not turn a successful
|
|
2302
|
+
// check-in into a failed one, nor a usage read into a rejection
|
|
901
2303
|
}
|
|
902
2304
|
}
|
|
903
2305
|
|
|
@@ -907,16 +2309,22 @@ const signedOut = (e) => Boolean(e?.signIn === "expired" || e?.expired)
|
|
|
907
2309
|
// object without a message, a host's own rejection value — would reach String()
|
|
908
2310
|
// as "[object Object]", which tells the user nothing and reads like a bug in the
|
|
909
2311
|
// plugin. The empty string lets the caller say something of its own instead.
|
|
910
|
-
const whyOf = (e) =>
|
|
911
|
-
typeof e?.message === "string" && e.message ? e.message : typeof e === "string" ? e : ""
|
|
2312
|
+
const whyOf = (e) => {
|
|
2313
|
+
const m = typeof e?.message === "string" && e.message ? e.message : typeof e === "string" ? e : ""
|
|
2314
|
+
// a body nested deep enough to overflow the identity pass is a shape this
|
|
2315
|
+
// plugin does not read, and the card must not carry the runtime's English
|
|
2316
|
+
// sentence for it
|
|
2317
|
+
return m.includes("Maximum call stack size exceeded") ? "账单格式不认识:嵌套过深" : m
|
|
2318
|
+
}
|
|
912
2319
|
|
|
913
2320
|
// EXPIRED is what a 401 says, wherever it is met: the ledger read before the
|
|
914
2321
|
// touch, the touch itself, or either of usage's two reads. Three wordings
|
|
915
|
-
// used to exist for one condition ("
|
|
916
|
-
// "
|
|
2322
|
+
// used to exist for one condition ("the token is invalid or not from the CN
|
|
2323
|
+
// site", the same plus an HTTP 401 suffix, and a bare "HTTP 401" from the
|
|
2324
|
+
// balance/ledger path), and the
|
|
917
2325
|
// README's troubleshooting row promises the user will see the status code in
|
|
918
|
-
// it — which only two of the three carried. `where` is the profile's
|
|
919
|
-
//
|
|
2326
|
+
// it — which only two of the three carried. `where` is the profile's CN site /
|
|
2327
|
+
// INT site, and stays in the one wording so the card says which site refused
|
|
920
2328
|
// the token.
|
|
921
2329
|
const expired = (where) => `令牌失效或不属于${where}(HTTP 401)`
|
|
922
2330
|
|
|
@@ -934,19 +2342,47 @@ const RISK = "被魔搭风控拒绝(HTTP 403)"
|
|
|
934
2342
|
// gate turns a reply that is not an answer into the error it is, so the four
|
|
935
2343
|
// reads that talk to ModelScope (the ledger, the touch, usage's balance and
|
|
936
2344
|
// usage's ledger) do not each carry their own copy of the four checks. `what`
|
|
937
|
-
// names the read in the message ("
|
|
938
|
-
//
|
|
939
|
-
// retry it can promise. `profile` is there for
|
|
940
|
-
// 401 carries (
|
|
2345
|
+
// names the read in the message ("ledger query failed" / "balance query
|
|
2346
|
+
// failed" / "login endpoint failed"); `risk` is the 403 wording, which the
|
|
2347
|
+
// touch alone extends with the retry it can promise. `profile` is there for
|
|
2348
|
+
// the one site-specific word a 401 carries (the CN site / the INT site).
|
|
941
2349
|
//
|
|
942
2350
|
// The parse check and the status check are separate on purpose: a body that
|
|
943
2351
|
// does not parse is not JSON; a body that parses and carries a 429/500 is the
|
|
944
|
-
// server saying why in its own words (
|
|
945
|
-
// a JSON-carrying 429 was reported as "
|
|
946
|
-
// check their proxy.
|
|
2352
|
+
// server saying why in its own words ("too many requests" / "system busy").
|
|
2353
|
+
// Merged, a JSON-carrying 429 was reported as "the response is not JSON" and
|
|
2354
|
+
// sent the user to check their proxy.
|
|
947
2355
|
const gate = (profile, res, body, parsed, token, what, risk = RISK) => {
|
|
948
|
-
|
|
949
|
-
|
|
2356
|
+
// A 401 is the token being refused only when it is ModelScope's own JSON.
|
|
2357
|
+
// A proxy's HTML 401 ("proxy authentication required") is the path, not the
|
|
2358
|
+
// sign-in, and signing the account out over it costs the user their login:
|
|
2359
|
+
// it falls through to the ordinary error, and the host retries in half an
|
|
2360
|
+
// hour.
|
|
2361
|
+
// the same question at the other two statuses: a 401 or a 403 that does not
|
|
2362
|
+
// read as ModelScope's is the path answering, not the account — a proxy's
|
|
2363
|
+
// challenge JSON said the sign-in expired, and a proxy's HTML 403 was
|
|
2364
|
+
// reported as ModelScope's risk control
|
|
2365
|
+
if (res.status === 401 && parsed && !awaitsSignIn(body)) throw new Error(`${what}:HTTP 401(响应不是魔搭的)`)
|
|
2366
|
+
if (res.status === 401 && parsed) throw deadToken(profile.where)
|
|
2367
|
+
// a 403 is ModelScope's risk control only when it answers in its own shape
|
|
2368
|
+
// (the success envelope, words in it); a proxy's "proxy denied" page or its
|
|
2369
|
+
// "authentication required" JSON is the path, and is reported as a plain
|
|
2370
|
+
// refusal of that status
|
|
2371
|
+
// ModelScope's own envelope is success or Code/data, in either case, and a
|
|
2372
|
+
// success:false is the site's too (a login layer's {"Success":false,
|
|
2373
|
+
// "Message":"RiskControl"}); a plain refusal with words in it and none of
|
|
2374
|
+
// those fields is any gateway's error page
|
|
2375
|
+
// `in` needs a value with fields: a JSON body of 17, true or "busy" parses
|
|
2376
|
+
// fine, and asking it for "success" threw the engine's own English sentence
|
|
2377
|
+
// ("Cannot use 'in' operator to search for 'success' in 17") at the hook,
|
|
2378
|
+
// with the request's name and the HTTP status lost. A non-object body is a
|
|
2379
|
+
// reply this plugin does not understand, and says so below.
|
|
2380
|
+
const shaped = parsed && body !== null && typeof body === "object"
|
|
2381
|
+
const envelope = shaped && !Array.isArray(body) && (("success" in body) || ("Success" in body))
|
|
2382
|
+
const msLogin = shaped && !Array.isArray(body) && (("Code" in body) || ("code" in body) || ("data" in body) || ("Data" in body))
|
|
2383
|
+
const pathSaid = PATH_MARK.test(JSON.stringify(body))
|
|
2384
|
+
if (res.status === 403 && !pathSaid && (envelope || msLogin)) throw new Error(risk)
|
|
2385
|
+
if (res.status === 403) throw new Error(`${what}:响应不是魔搭的(HTTP 403)`)
|
|
950
2386
|
if (!parsed) throw new Error(`${what}:响应不是 JSON(HTTP ${res.status})`)
|
|
951
2387
|
if (res.status !== 200 || !okOf(body)) throw new Error(`${what}:${saidOf(body, token) || `HTTP ${res.status}`}`)
|
|
952
2388
|
}
|
|
@@ -955,11 +2391,526 @@ const gate = (profile, res, body, parsed, token, what, risk = RISK) => {
|
|
|
955
2391
|
// The day is read at each call, on purpose: a check-in that crosses midnight
|
|
956
2392
|
// between its reads reports what its touch granted on the new day, which is
|
|
957
2393
|
// true, where diffing both reads against the old day would answer inactive.
|
|
958
|
-
async function todayEarn(
|
|
959
|
-
const { res, body, parsed } = await
|
|
2394
|
+
async function todayEarn(token, deadline, opts = {}) {
|
|
2395
|
+
const { profile, res, body, parsed } = await siteRead(token, `${TXNS}?page=1&page_size=${PAGE_SIZE}`, deadline)
|
|
960
2396
|
gate(profile, res, body, parsed, token, "账单查询失败")
|
|
961
2397
|
const day = todayCN()
|
|
962
|
-
|
|
2398
|
+
const first = recordsOf(body)
|
|
2399
|
+
const { all, pages, complete, scanComplete, last, seenPages, seenRows, total, refused } = await dayRows(profile, token, day, first, deadline, body, opts)
|
|
2400
|
+
return {
|
|
2401
|
+
profile,
|
|
2402
|
+
day,
|
|
2403
|
+
all,
|
|
2404
|
+
pages,
|
|
2405
|
+
complete,
|
|
2406
|
+
scanComplete,
|
|
2407
|
+
refused,
|
|
2408
|
+
last,
|
|
2409
|
+
seenPages,
|
|
2410
|
+
seenRows,
|
|
2411
|
+
// the number the walk finished on, not the first page's: a total that grew
|
|
2412
|
+
// on a later page (or was printed only there) is the one that says whether
|
|
2413
|
+
// the streak's next page is worth reading
|
|
2414
|
+
total,
|
|
2415
|
+
rows: all.filter((r) => r?.type === "EARN" && onDay(r?.gmt_created, day)),
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
// DAY_PAGES bounds the extra ledger reads a page full of today's rows costs.
|
|
2420
|
+
// Rows come newest first, so today's rows sit at the top and spill onto the
|
|
2421
|
+
// next page only when today alone holds more rows than a page. A page whose
|
|
2422
|
+
// oldest row is already before `day` is where they end.
|
|
2423
|
+
const DAY_PAGES = 4
|
|
2424
|
+
|
|
2425
|
+
// dayRows returns the rows the caller already read plus the pages that can
|
|
2426
|
+
// still hold `day`'s rows, and whether the walk proved it saw the end of
|
|
2427
|
+
// them. A page is followed when it is full and holds nothing older than
|
|
2428
|
+
// today, and — the case a server contradicts itself in — when it has room to
|
|
2429
|
+
// spare but its `total` still promises rows this walk has not seen: a short
|
|
2430
|
+
// page that is not the last one used to end the walk, and a day whose
|
|
2431
|
+
// remaining rows sat behind it was read as a complete ledger.
|
|
2432
|
+
//
|
|
2433
|
+
// `complete` says the end was proved: a page older than `day`, a page with
|
|
2434
|
+
// room to spare whose `total` the rows in hand account for, or an empty page
|
|
2435
|
+
// with no `total`. An extra page that fails (a transport error, a 5xx, a body
|
|
2436
|
+
// that is not JSON), a budget already gone, or a `total` still ahead of the
|
|
2437
|
+
// rows stops the walk with `complete` false — the numbers are a floor, and
|
|
2438
|
+
// the check-in must not read "no grant today" into a ledger it could not
|
|
2439
|
+
// finish. `pages` is the highest page read, so the streak walk resumes after
|
|
2440
|
+
// it instead of asking for a page twice (the second read counted its rows
|
|
2441
|
+
// again and made the ledger look finished one page early).
|
|
2442
|
+
// stable is a value's identity with its object keys sorted: JSON.stringify
|
|
2443
|
+
// keeps insertion order, so the same rows with their fields in another order
|
|
2444
|
+
// read as a different page and were summed twice. `deep` bounds the recursion:
|
|
2445
|
+
// a body nested thousands deep overflowed the stack and answered the card with
|
|
2446
|
+
// the runtime's English sentence; past the bound a value is identified by its
|
|
2447
|
+
// depth alone, which keeps the pass cheap and total (a server that sends such
|
|
2448
|
+
// a row has no identity to lose).
|
|
2449
|
+
const STABLE_DEPTH = 64
|
|
2450
|
+
const stable = (v, deep = 0) =>
|
|
2451
|
+
deep > STABLE_DEPTH
|
|
2452
|
+
? `"<depth ${deep}>"`
|
|
2453
|
+
: Array.isArray(v)
|
|
2454
|
+
? `[${v.map((x) => stable(x, deep + 1)).join(",")}]`
|
|
2455
|
+
: v && typeof v === "object"
|
|
2456
|
+
? `{${Object.keys(v)
|
|
2457
|
+
.sort()
|
|
2458
|
+
.map((k) => `${JSON.stringify(k)}:${stable(v[k], deep + 1)}`)
|
|
2459
|
+
.join(",")}}`
|
|
2460
|
+
: JSON.stringify(v)
|
|
2461
|
+
const rowKey = (r) => stable(r)
|
|
2462
|
+
// idOf is the row's identity as the server names it: `id` when the row has
|
|
2463
|
+
// one, the whole-row key when it does not. The ledger may recount an
|
|
2464
|
+
// aggregate between two page reads — same id, new numbers — and the
|
|
2465
|
+
// whole-row key read that as a second row (12 credits of spend showed as 22).
|
|
2466
|
+
// Rows without an id are not merged: two rows that happen to share a tuple
|
|
2467
|
+
// are still two rows unless the server says they are one.
|
|
2468
|
+
const idOf = (r) =>
|
|
2469
|
+
r?.id !== undefined && r?.id !== null && r.id !== "" ? `id:${String(r.id)}` : ""
|
|
2470
|
+
|
|
2471
|
+
async function dayRows(profile, token, day, first, deadline, body, { expiry = true } = {}) {
|
|
2472
|
+
const all = [...first]
|
|
2473
|
+
let rows = first
|
|
2474
|
+
let pages = 1
|
|
2475
|
+
// A page seen before is the page number being ignored, and a row seen before
|
|
2476
|
+
// is the server shifting its window between reads: the same grant then sits
|
|
2477
|
+
// at the bottom of one page and the top of the next, and summing both
|
|
2478
|
+
// reports it twice.
|
|
2479
|
+
const seenPages = new Set([stable(first)])
|
|
2480
|
+
const seenRows = new Set(first.map(rowKey))
|
|
2481
|
+
// the aggregate behind each row already counted, and where it sits in `all`:
|
|
2482
|
+
// a row that comes back with the same identity but new numbers replaces the
|
|
2483
|
+
// one in hand (the later read is the newer read) instead of being summed
|
|
2484
|
+
// beside it
|
|
2485
|
+
const byId = new Map()
|
|
2486
|
+
first.forEach((r, i) => {
|
|
2487
|
+
const id = idOf(r)
|
|
2488
|
+
if (id && !byId.has(id)) byId.set(id, i)
|
|
2489
|
+
})
|
|
2490
|
+
let { total, contradicted } = totalOf(body, first)
|
|
2491
|
+
let complete = false
|
|
2492
|
+
// Two questions end this walk, and they are not the same one. The day's
|
|
2493
|
+
// numbers are proven complete by a page older than the day, a total the rows
|
|
2494
|
+
// account for, or a page with room to spare. The card's countdown needs
|
|
2495
|
+
// more: a daily grant runs 24 hours, so yesterday's grant can be the soonest
|
|
2496
|
+
// to expire, and only a row older than yesterday proves that none can
|
|
2497
|
+
// follow. Once the numbers are proven the walk keeps reading — bounded by
|
|
2498
|
+
// the page cap and the budget — for that scan alone, and a page that fails
|
|
2499
|
+
// there ends the scan without touching the numbers: a 401 met after the
|
|
2500
|
+
// claim still does not undo the claim.
|
|
2501
|
+
const dayOf = (r) => String(r?.gmt_created ?? "").slice(0, 10)
|
|
2502
|
+
const yesterday = new Date(Date.parse(`${day}T00:00:00Z`) - 86400e3).toISOString().slice(0, 10)
|
|
2503
|
+
let dayDone = false
|
|
2504
|
+
// whether the countdown's scan reached the point where no running grant can
|
|
2505
|
+
// follow (a page holding nothing from yesterday or today). The numbers can
|
|
2506
|
+
// be proven whole while this is still open: the card then shows the
|
|
2507
|
+
// earliest expiry it saw and says the scan was cut short.
|
|
2508
|
+
let scanDone = false
|
|
2509
|
+
// the token was refused on a courtesy page: the numbers stand, and the
|
|
2510
|
+
// account is expired (the caller reports it)
|
|
2511
|
+
let refused = false
|
|
2512
|
+
// the walk ended on a page that reached back past the day: the only ending
|
|
2513
|
+
// where another page can still hold a running grant
|
|
2514
|
+
let endedByOlder = false
|
|
2515
|
+
// no row on the page is older than yesterday: a page that reached back past
|
|
2516
|
+
// the day and holds none of those closes the countdown's window, because a
|
|
2517
|
+
// running grant is at most 24 hours old and the rows are newest first
|
|
2518
|
+
const noOlderThanYesterday = (rs) =>
|
|
2519
|
+
!rs.some((r) => {
|
|
2520
|
+
const s = dayOf(r)
|
|
2521
|
+
return isDay(s) && s < yesterday
|
|
2522
|
+
})
|
|
2523
|
+
for (;;) {
|
|
2524
|
+
const older = rows.some((r) => {
|
|
2525
|
+
const s = dayOf(r)
|
|
2526
|
+
return isDay(s) && s < day
|
|
2527
|
+
})
|
|
2528
|
+
if (!dayDone) {
|
|
2529
|
+
if (older) {
|
|
2530
|
+
dayDone = true
|
|
2531
|
+
endedByOlder = true
|
|
2532
|
+
}
|
|
2533
|
+
// The number the page printed beside its rows is the whole ledger's
|
|
2534
|
+
// count: when the rows in hand already account for it, there is no next
|
|
2535
|
+
// page to read for the day's numbers, whether this page was full or not.
|
|
2536
|
+
else if (!contradicted && total !== null && all.length >= total) dayDone = true
|
|
2537
|
+
}
|
|
2538
|
+
// A full page with no `total` that the walk stopped reading (the page cap,
|
|
2539
|
+
// the budget) is a truncation, not an end: the ledger may continue past
|
|
2540
|
+
// it. It used to be called complete here, and a day with 300 rows answered
|
|
2541
|
+
// 250 with no "未读完" on the card and no failed on the check-in. Proving
|
|
2542
|
+
// the end needs a page older than the day, a page with room to spare, or a
|
|
2543
|
+
// total the rows account for. Once proven, a cut costs only the scan.
|
|
2544
|
+
if (total === null && rows.length >= PAGE_SIZE && (pages > DAY_PAGES || deadline <= Date.now())) {
|
|
2545
|
+
complete = dayDone
|
|
2546
|
+
// the page the cap stopped on can already be the window's end: it
|
|
2547
|
+
// reached past the day and holds a row older than yesterday, which is
|
|
2548
|
+
// the same evidence the walk below asks for. Without this the card
|
|
2549
|
+
// printed "到期时间可能更早" for a window the rows in hand had closed.
|
|
2550
|
+
// A page of nothing but today's rows closes nothing — the cap cut the
|
|
2551
|
+
// walk before yesterday's grants were read, and the scan is a floor —
|
|
2552
|
+
// and neither does one that reached yesterday but stopped short of an
|
|
2553
|
+
// older row: the next page can still carry one that expires sooner.
|
|
2554
|
+
scanDone = !expiry || (endedByOlder && !noOlderThanYesterday(rows))
|
|
2555
|
+
break
|
|
2556
|
+
}
|
|
2557
|
+
// a page with room to spare is the end — unless `total` still promises
|
|
2558
|
+
// rows, in which case the server is contradicting itself and the rest may
|
|
2559
|
+
// be on the next page anyway (bounded by DAY_PAGES below)
|
|
2560
|
+
// a `total` below the rows beside it is the ledger contradicting itself:
|
|
2561
|
+
// it proves neither "this is the end" nor "there is more", so the page is
|
|
2562
|
+
// walked on like a short page with no total at all
|
|
2563
|
+
if (!dayDone && rows.length < PAGE_SIZE && !contradicted && (total === null || all.length >= total)) dayDone = true
|
|
2564
|
+
if (dayDone) {
|
|
2565
|
+
// A near-running grant must have landed within the last 24 hours, so it
|
|
2566
|
+
// sits in yesterday's rows or today's. Another page is worth reading
|
|
2567
|
+
// only when this one reached back into yesterday and holds nothing older
|
|
2568
|
+
// than it: the numbers' own endings (a total the rows account for, a
|
|
2569
|
+
// page with room to spare) mean the ledger was read to its end already
|
|
2570
|
+
// and cost no second read.
|
|
2571
|
+
const reached = endedByOlder && !rows.some((r) => {
|
|
2572
|
+
const s = dayOf(r)
|
|
2573
|
+
return isDay(s) && s < yesterday
|
|
2574
|
+
})
|
|
2575
|
+
if (!expiry || !reached || pages > DAY_PAGES || deadline <= Date.now()) {
|
|
2576
|
+
complete = true
|
|
2577
|
+
scanDone = !expiry || !reached
|
|
2578
|
+
break
|
|
2579
|
+
}
|
|
2580
|
+
} else if (pages > DAY_PAGES || deadline <= Date.now()) {
|
|
2581
|
+
// the page cap cut a walk whose numbers were already proven: the day is
|
|
2582
|
+
// complete (an older page or a total said so), and only the countdown's
|
|
2583
|
+
// scan is cut short — the check-in must not read this as a book it could
|
|
2584
|
+
// not finish
|
|
2585
|
+
if (dayDone) complete = true
|
|
2586
|
+
break
|
|
2587
|
+
}
|
|
2588
|
+
let got
|
|
2589
|
+
try {
|
|
2590
|
+
got = await json(profile.site, token, `${TXNS}?page=${pages + 1}&page_size=${PAGE_SIZE}`, deadline)
|
|
2591
|
+
} catch {
|
|
2592
|
+
if (dayDone) complete = true
|
|
2593
|
+
break
|
|
2594
|
+
}
|
|
2595
|
+
const { res } = got
|
|
2596
|
+
// the same rule gate applies to the first page: only a 200 carrying the
|
|
2597
|
+
// success envelope is an answer. A 202 used to be read as the end of the
|
|
2598
|
+
// ledger, which confirmed a day that was never finished. A 401 is the same
|
|
2599
|
+
// token being refused, and swallowing it here kept a confirmed expiry off
|
|
2600
|
+
// the card and out of the check-in's answer: it goes through the same
|
|
2601
|
+
// dead-token throw the first page's gate uses.
|
|
2602
|
+
if (got.parsed && res.status === 401 && awaitsSignIn(got.body)) {
|
|
2603
|
+
// a refusal on a courtesy page (the numbers already proven) ends the
|
|
2604
|
+
// scan without touching them: the claim it was read for stands, as it
|
|
2605
|
+
// did when the walk stopped before this page at all
|
|
2606
|
+
if (dayDone) {
|
|
2607
|
+
complete = true
|
|
2608
|
+
// a refusal on a courtesy page ends the scan without touching the
|
|
2609
|
+
// numbers — but the scan did not reach its end, and the token *was*
|
|
2610
|
+
// refused: the card says the expiry it shows is a floor, and the
|
|
2611
|
+
// account is reported expired instead of the refusal being swallowed
|
|
2612
|
+
refused = true
|
|
2613
|
+
break
|
|
2614
|
+
}
|
|
2615
|
+
throw deadToken(profile.where)
|
|
2616
|
+
}
|
|
2617
|
+
if (!got.parsed || res.status !== 200 || !okOf(got.body)) {
|
|
2618
|
+
if (dayDone) complete = true
|
|
2619
|
+
break
|
|
2620
|
+
}
|
|
2621
|
+
// a page whose shape this walk cannot read — no data, records not a list —
|
|
2622
|
+
// is a page it failed to read, the same as a 500 on it: the walk stops with
|
|
2623
|
+
// the rows it has and `complete` false, so the card says the number is low
|
|
2624
|
+
// and the check-in retries. Letting recordsOf throw here failed the whole
|
|
2625
|
+
// hook with its sentence and threw away the first page's rows.
|
|
2626
|
+
let raw
|
|
2627
|
+
let pageTotal
|
|
2628
|
+
try {
|
|
2629
|
+
raw = recordsOf(got.body)
|
|
2630
|
+
pages += 1
|
|
2631
|
+
// the page's own `total` is checked the way the first page's was: a page
|
|
2632
|
+
// that prints fewer rows than its number promises is contradicting itself
|
|
2633
|
+
// and cannot end the walk on that number.
|
|
2634
|
+
pageTotal = totalOf(got.body, raw)
|
|
2635
|
+
} catch {
|
|
2636
|
+
if (dayDone) complete = true
|
|
2637
|
+
break
|
|
2638
|
+
}
|
|
2639
|
+
if (pageTotal.contradicted) {
|
|
2640
|
+
if (dayDone) complete = true
|
|
2641
|
+
break
|
|
2642
|
+
}
|
|
2643
|
+
// the page's own `total` is the ledger's count as the server sees it now;
|
|
2644
|
+
// a ledger that grew between the reads printed 51 on the first page and 52
|
|
2645
|
+
// on the second, and the walk ended on the first page's number with one
|
|
2646
|
+
// row still unread. The latest number wins, and a total that has grown
|
|
2647
|
+
// keeps the walk going. Only a number the server *printed* can win: a
|
|
2648
|
+
// short page that omits `total` synthesises one from the rows in hand,
|
|
2649
|
+
// and overwriting the known total with it ended a walk before the page
|
|
2650
|
+
// that held the rest of the day's rows (measured: 52 promised, 5 seen,
|
|
2651
|
+
// page 3 never read).
|
|
2652
|
+
//
|
|
2653
|
+
// A printed total below the rows already read, or below one an earlier page
|
|
2654
|
+
// printed, is the ledger contradicting itself — 50 read, then a page
|
|
2655
|
+
// claiming 52 while the rows in hand pass it — and adopting it would end
|
|
2656
|
+
// the walk on the number it just refuted. The walk stops here, incomplete.
|
|
2657
|
+
if (!dayDone && pageTotal.said !== null) {
|
|
2658
|
+
if (pageTotal.said < all.length || (total !== null && pageTotal.said < total)) break
|
|
2659
|
+
total = pageTotal.said
|
|
2660
|
+
}
|
|
2661
|
+
// an empty page is the end of the ledger, or — with a `total` still
|
|
2662
|
+
// counting rows — the server saying two things at once; no page after it
|
|
2663
|
+
// is worth reading. A page repeating one already read is the page number
|
|
2664
|
+
// being ignored, and summing it again would inflate the day's numbers.
|
|
2665
|
+
const stamp = stable(raw)
|
|
2666
|
+
if (!raw.length || seenPages.has(stamp)) {
|
|
2667
|
+
complete = dayDone || (!raw.length && total === null ? true : total !== null && all.length >= total)
|
|
2668
|
+
// an empty page is the end of the ledger: no running grant can follow it.
|
|
2669
|
+
// A repeated page is the server ignoring the page number — the rows are
|
|
2670
|
+
// the ones already read, and a grant older than them (which runs 24 hours
|
|
2671
|
+
// and can expire sooner) may sit on a page the walk never reached, so the
|
|
2672
|
+
// countdown stays a floor there.
|
|
2673
|
+
scanDone = !raw.length
|
|
2674
|
+
break
|
|
2675
|
+
}
|
|
2676
|
+
seenPages.add(stamp)
|
|
2677
|
+
// a row seen before is skipped; the same aggregate with new numbers is not
|
|
2678
|
+
// "fresh" — it is the row in hand, updated
|
|
2679
|
+
const fresh = []
|
|
2680
|
+
const added = []
|
|
2681
|
+
let changed = false
|
|
2682
|
+
for (const r of raw) {
|
|
2683
|
+
const k = rowKey(r)
|
|
2684
|
+
if (seenRows.has(k)) continue
|
|
2685
|
+
const id = idOf(r)
|
|
2686
|
+
// the same id seen before is the same aggregate: the later read replaces
|
|
2687
|
+
// it. Rows the server gives no id for are never merged — two rows that
|
|
2688
|
+
// share a tuple are still two rows.
|
|
2689
|
+
const at = id && byId.has(id) ? byId.get(id) : undefined
|
|
2690
|
+
if (at !== undefined) {
|
|
2691
|
+
all[at] = r
|
|
2692
|
+
added.push(k)
|
|
2693
|
+
changed = true
|
|
2694
|
+
continue
|
|
2695
|
+
}
|
|
2696
|
+
if (id) byId.set(id, all.length + fresh.length)
|
|
2697
|
+
fresh.push(r)
|
|
2698
|
+
added.push(k)
|
|
2699
|
+
}
|
|
2700
|
+
// the set is updated after the page is read, as before: a page that
|
|
2701
|
+
// repeats a row inside itself is summed as many times as it says (the old
|
|
2702
|
+
// filter compared against a snapshot), and a replaced row's new spelling
|
|
2703
|
+
// joins it for the pages after
|
|
2704
|
+
for (const k of added) seenRows.add(k)
|
|
2705
|
+
if (!fresh.length && !changed) {
|
|
2706
|
+
// every row on this page was already counted: the rows are not growing,
|
|
2707
|
+
// and reading on would not change the numbers. That is a fact about the
|
|
2708
|
+
// numbers, not about the window: another page can still hold a running
|
|
2709
|
+
// grant, so the countdown's scan is cut here unless it was asked not to
|
|
2710
|
+
// scan at all (a cut scan is a floor, and the card says so)
|
|
2711
|
+
complete = dayDone || (total !== null && all.length >= total)
|
|
2712
|
+
break
|
|
2713
|
+
}
|
|
2714
|
+
// the page-end proof asks the page that the server sent, not the rows this
|
|
2715
|
+
// walk counted: one repeat on a full page left 49 counted rows, "less than
|
|
2716
|
+
// 50" read as room to spare, and the rest of the ledger — today's grant
|
|
2717
|
+
// included — was never read. A page of nothing but repeats has its own
|
|
2718
|
+
// ending above (an empty or repeated page is the end of the ledger).
|
|
2719
|
+
rows = raw
|
|
2720
|
+
all.push(...fresh)
|
|
2721
|
+
}
|
|
2722
|
+
// `last` is the page the walk stopped on, for a following page-reader to
|
|
2723
|
+
// recognise as already read. It is handed over only when the walk stopped
|
|
2724
|
+
// *without* proving the end (a failed page, a spent budget): there the next
|
|
2725
|
+
// page is the same one again, and reading it twice is what inflated the day.
|
|
2726
|
+
// A walk that proved the end has nothing after it, and an empty page is the
|
|
2727
|
+
// end whatever `total` said — seeding either would make the ledger's next
|
|
2728
|
+
// ask look like a repeat and silence a streak that was read to the last day.
|
|
2729
|
+
return {
|
|
2730
|
+
all,
|
|
2731
|
+
pages,
|
|
2732
|
+
complete,
|
|
2733
|
+
// whether the countdown's own scan reached its end (see scanDone above);
|
|
2734
|
+
// a walk that was not asked to scan at all never started one
|
|
2735
|
+
scanComplete: !expiry || scanDone,
|
|
2736
|
+
refused,
|
|
2737
|
+
total,
|
|
2738
|
+
last: complete || !rows.length ? null : rows,
|
|
2739
|
+
seenPages,
|
|
2740
|
+
seenRows,
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
// totalOf is how many rows the ledger holds altogether, from the `total` the
|
|
2745
|
+
// page carries beside its `records`; null when that cannot be known. It is
|
|
2746
|
+
// what tells a full page from a complete one: the page size caps at 50 (the
|
|
2747
|
+
// server answers page_size=200 with 50), so a page that comes back with 50
|
|
2748
|
+
// rows may be the whole ledger or the first sliver of it. A page with room to
|
|
2749
|
+
// spare is complete by definition, so a missing `total` is answered by the
|
|
2750
|
+
// rows in hand there; a full page without one is unknown, and the streak walk
|
|
2751
|
+
// leaves its answer unsaid rather than treat the page as the whole ledger.
|
|
2752
|
+
//
|
|
2753
|
+
// `said` is the distinction the two callers need: the number the server
|
|
2754
|
+
// printed, or null. A short page that prints no `total` synthesises one from
|
|
2755
|
+
// the rows in hand — correct to read as "that is the whole ledger" for the page
|
|
2756
|
+
// it was computed from, and wrong to *overwrite* a number an earlier page
|
|
2757
|
+
// printed. Measured: a ledger whose first page promised 52 rows, whose second
|
|
2758
|
+
// carried 5 and no total, had the known 52 replaced by the 5 it could see, the
|
|
2759
|
+
// walk stopped at 51 rows, and the 300 credits on page 3 were never read.
|
|
2760
|
+
const totalOf = (body, rows) => {
|
|
2761
|
+
const n = dataObjectOf(body, "账单格式不认识:没有 data").total
|
|
2762
|
+
// a total below the rows it is printed beside is the ledger contradicting
|
|
2763
|
+
// itself; neither number can be trusted to say whether the page is whole.
|
|
2764
|
+
// That is not the same as a missing total, which the caller reads as "the
|
|
2765
|
+
// rows in hand are the ledger": a short page of a contradicted ledger must
|
|
2766
|
+
// not be called complete on the strength of the number it just refuted.
|
|
2767
|
+
//
|
|
2768
|
+
// A total of another type ("60", 5.5) is the same contradiction, not a
|
|
2769
|
+
// missing one: the server printed a number it did not stand behind, and
|
|
2770
|
+
// reading the short page beside it as "that is the whole ledger" ends the
|
|
2771
|
+
// walk before the pages that hold the rest of today's rows.
|
|
2772
|
+
if (n !== undefined && n !== null && !Number.isInteger(n)) return { total: null, said: null, contradicted: true }
|
|
2773
|
+
if (Number.isInteger(n)) return n >= rows.length ? { total: n, said: n, contradicted: false } : { total: null, said: null, contradicted: true }
|
|
2774
|
+
return { total: rows.length < PAGE_SIZE ? rows.length : null, said: null, contradicted: false }
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
// prevDay is the Beijing day before `day`. The rows carry date labels with no
|
|
2778
|
+
// zone attached, so subtracting on the UTC date is right: the label is a
|
|
2779
|
+
// calendar day, not an instant.
|
|
2780
|
+
// isDay is a stamp's first ten characters when they name a day that exists.
|
|
2781
|
+
// The shape alone let "2026-00-00" through: it sorts before every real day, so
|
|
2782
|
+
// it read as an old row and ended a walk on the spot.
|
|
2783
|
+
const isDay = (s) => {
|
|
2784
|
+
if (typeof s !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return false
|
|
2785
|
+
const d = new Date(`${s}T00:00:00Z`)
|
|
2786
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === s
|
|
2787
|
+
}
|
|
2788
|
+
const prevDay = (day) => {
|
|
2789
|
+
const d = new Date(`${day}T00:00:00Z`)
|
|
2790
|
+
d.setUTCDate(d.getUTCDate() - 1)
|
|
2791
|
+
return d.toISOString().slice(0, 10)
|
|
2792
|
+
}
|
|
2793
|
+
|
|
2794
|
+
// STREAK_PAGES caps how far back the streak walk may read. One page reaches
|
|
2795
|
+
// back a fortnight or so on a live account (3–7 rows a day); a run longer
|
|
2796
|
+
// than that is followed onto the next page, because on the account this was
|
|
2797
|
+
// written for the whole ledger held 16 days of daily grants on page 1 and one
|
|
2798
|
+
// row on page 2 — without the second page the run was silently unknown. Eight
|
|
2799
|
+
// pages is ~100 days of rows: past that the walk stops and the streak is left
|
|
2800
|
+
// unsaid, rather than spend a check-in's budget paging through a year.
|
|
2801
|
+
const STREAK_PAGES = 8
|
|
2802
|
+
|
|
2803
|
+
// daysWithGrant is the set of Beijing days a list of ledger rows grants a
|
|
2804
|
+
// daily rule on, in the "YYYY-MM-DD" shape the rows' stamps start with. A
|
|
2805
|
+
// stamp that is not a date is skipped: it does not say which day it grants,
|
|
2806
|
+
// so it may not stand for one. (The date shape that matters for the walk
|
|
2807
|
+
// itself is checked where stamps are compared as "older than" — see dayRows
|
|
2808
|
+
// and streakOf: an empty stamp sorted before every real day and ended a run.)
|
|
2809
|
+
const daysWithGrant = (rows, into = new Set()) => {
|
|
2810
|
+
for (const r of rows) {
|
|
2811
|
+
const stamp = typeof r?.gmt_created === "string" ? r.gmt_created.slice(0, 10) : ""
|
|
2812
|
+
if (isDay(stamp) && r?.type === "EARN" && DAILY_RULES.includes(r?.rule_key)) into.add(stamp)
|
|
2813
|
+
}
|
|
2814
|
+
return into
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
// streakOf is how many consecutive Beijing days up to `day` carried a daily
|
|
2818
|
+
// grant — the run magpie prints as "N-day streak" beside a check-in. The
|
|
2819
|
+
// first page comes from the check-in's own ledger read; when the run is still
|
|
2820
|
+
// going at the oldest row of a page whose `total` says there is more, the
|
|
2821
|
+
// next page is read (readPage) and the walk carries on. The answer is
|
|
2822
|
+
// undefined when today's grant is not in the rows, and when the walk runs out
|
|
2823
|
+
// of pages first: magpie prints any number it is given, so a run it could not
|
|
2824
|
+
// see the end of is not told at all rather than told short.
|
|
2825
|
+
async function streakOf(records, day, total, readPage, deadline) {
|
|
2826
|
+
const days = daysWithGrant(records)
|
|
2827
|
+
if (!days.has(day)) return undefined
|
|
2828
|
+
let held = records.length
|
|
2829
|
+
let n = 1
|
|
2830
|
+
let d = day
|
|
2831
|
+
for (;;) {
|
|
2832
|
+
const prev = prevDay(d)
|
|
2833
|
+
if (days.has(prev)) {
|
|
2834
|
+
n++
|
|
2835
|
+
d = prev
|
|
2836
|
+
continue
|
|
2837
|
+
}
|
|
2838
|
+
// Nothing grants on `prev`. That is a real break when a row older than it
|
|
2839
|
+
// has been read — the ledger goes on past the gap and still says nothing
|
|
2840
|
+
// that day — or when the rows read are the whole ledger. `total` is null
|
|
2841
|
+
// when a full page did not say how many rows the ledger holds, or said a
|
|
2842
|
+
// number the rows contradict; the next page is the only way to learn
|
|
2843
|
+
// whether that page was the whole ledger, so it is read when there is one.
|
|
2844
|
+
// The date part is compared only when it is a date: an empty or malformed
|
|
2845
|
+
// stamp sorted before every real day and ended the run on the spot.
|
|
2846
|
+
const older = records.some((r) => {
|
|
2847
|
+
const s = typeof r?.gmt_created === "string" ? r.gmt_created.slice(0, 10) : ""
|
|
2848
|
+
return isDay(s) && s < prev
|
|
2849
|
+
})
|
|
2850
|
+
if (older) return n
|
|
2851
|
+
if (total !== null && held >= total) return n
|
|
2852
|
+
if (!readPage || deadline <= Date.now()) return undefined
|
|
2853
|
+
const more = await readPage()
|
|
2854
|
+
if (more === null) return undefined
|
|
2855
|
+
// An empty page settles it: with a known `total` still counting rows it is
|
|
2856
|
+
// the ledger contradicting itself, and nothing after this can be trusted
|
|
2857
|
+
// to be the end of the run; with no `total`, it is the end of the ledger.
|
|
2858
|
+
if (!more.length) return total === null ? n : undefined
|
|
2859
|
+
daysWithGrant(more, days)
|
|
2860
|
+
records = more
|
|
2861
|
+
held += more.length
|
|
2862
|
+
}
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
// ledgerReader hands streakOf one page at a time, past the pages the check-in
|
|
2866
|
+
// already read. `from` is the highest page that read reached: asking for page
|
|
2867
|
+
// 2 again would append its rows a second time, and the doubled count made the
|
|
2868
|
+
// ledger look finished one page early. It gives up — null, which leaves the
|
|
2869
|
+
// streak unsaid — when STREAK_PAGES is spent in all, when a page cannot be
|
|
2870
|
+
// read (a 403, a budget already gone), or when the ledger answers with a page
|
|
2871
|
+
// that is not a list. A page error must not fail the check-in: the claim has
|
|
2872
|
+
// already happened, and the answer it deserves is the one already computed,
|
|
2873
|
+
// not a failure the host retries half an hour later.
|
|
2874
|
+
const ledgerReader = (profile, token, deadline, from = 1, first = null, seenPages = null, seenRows = null, marks = null) => {
|
|
2875
|
+
let page = from
|
|
2876
|
+
let left = Math.max(0, STREAK_PAGES - from)
|
|
2877
|
+
// every page and row the day walk read, not just the page it stopped on: a
|
|
2878
|
+
// server that cycles through its pages repeated one the walk had already
|
|
2879
|
+
// summed, and counting it again filled the ledger's `total` and let the
|
|
2880
|
+
// streak claim an end it never saw.
|
|
2881
|
+
const seen = new Set(seenPages ? [...seenPages] : [])
|
|
2882
|
+
if (first !== null) seen.add(stable(first))
|
|
2883
|
+
const counted = new Set(seenRows ? [...seenRows] : [])
|
|
2884
|
+
if (first !== null) for (const r of first) counted.add(rowKey(r))
|
|
2885
|
+
return async () => {
|
|
2886
|
+
if (left <= 0) return null
|
|
2887
|
+
left -= 1
|
|
2888
|
+
page += 1
|
|
2889
|
+
try {
|
|
2890
|
+
const { res, body, parsed } = await json(profile.site, token, `${TXNS}?page=${page}&page_size=${PAGE_SIZE}`, deadline)
|
|
2891
|
+
gate(profile, res, body, parsed, token, "账单查询失败")
|
|
2892
|
+
const rows = recordsOf(body)
|
|
2893
|
+
// a page already read (the server ignoring the page number, or cycling)
|
|
2894
|
+
// is not the walk's next step: counted again it fills the ledger's
|
|
2895
|
+
// `total` and ends a streak the walk could not see the end of
|
|
2896
|
+
const stamp = stable(rows)
|
|
2897
|
+
if (seen.has(stamp)) return null
|
|
2898
|
+
seen.add(stamp)
|
|
2899
|
+
// an empty page is the end of the ledger, not a repeat: streakOf reads
|
|
2900
|
+
// it as the proof that the run went as far as there is
|
|
2901
|
+
if (!rows.length) return []
|
|
2902
|
+
const fresh = rows.filter((r) => !counted.has(rowKey(r)))
|
|
2903
|
+
for (const r of fresh) counted.add(rowKey(r))
|
|
2904
|
+
if (!fresh.length) return null
|
|
2905
|
+
return fresh
|
|
2906
|
+
} catch (e) {
|
|
2907
|
+
// a 401 the gate confirmed is the token refused; the walk still gives up
|
|
2908
|
+
// softly (the claim already landed), but the host is told about the
|
|
2909
|
+
// expiry through `marks`
|
|
2910
|
+
if (marks && signedOut(e)) marks.expired = true
|
|
2911
|
+
return null
|
|
2912
|
+
}
|
|
2913
|
+
}
|
|
963
2914
|
}
|
|
964
2915
|
|
|
965
2916
|
// SURE_HOUR is magpie's checkinSureHour (workbuddy_checkin.go): before noon,
|
|
@@ -979,7 +2930,7 @@ const deferred = new Map()
|
|
|
979
2930
|
// nothing but whitespace. The empty string was already refused at both
|
|
980
2931
|
// numeric sites; the spaces were not, and `Number(" ")` is 0 — so a blank
|
|
981
2932
|
// amount read as "a grant of nothing" (the check-in answers `inactive`, which
|
|
982
|
-
// settles the day) and a blank balance as "0
|
|
2933
|
+
// settles the day) and a blank balance as "0 credits available". A padded number
|
|
983
2934
|
// (" 251.5 ") is not blank: it says what it means.
|
|
984
2935
|
const blankOf = (v) => v === undefined || v === null || (typeof v === "string" && !v.trim())
|
|
985
2936
|
|
|
@@ -988,7 +2939,7 @@ const blankOf = (v) => v === undefined || v === null || (typeof v === "string" &
|
|
|
988
2939
|
// 0: the same rule the whole body gets one level up. A localized "1,200", a
|
|
989
2940
|
// format change, a null — each would otherwise make a real grant look like
|
|
990
2941
|
// "nothing arrived": the check-in would answer `inactive` (which settles the
|
|
991
|
-
// day) and the card would say
|
|
2942
|
+
// day) and the card would say claimed 0 next to a balance that clearly moved.
|
|
992
2943
|
// Absent is included: a row with no amount cannot be summed either way.
|
|
993
2944
|
const amountOf = (row) => {
|
|
994
2945
|
const raw = row?.total_amount
|
|
@@ -1000,7 +2951,7 @@ const amountOf = (row) => {
|
|
|
1000
2951
|
const n = Number(raw)
|
|
1001
2952
|
// readable, not just isFinite: a huge finite value ("1e307") survives isFinite
|
|
1002
2953
|
// and only blows up at the card, where yin's ×100 overflows to Infinity — so
|
|
1003
|
-
// "Infinity
|
|
2954
|
+
// "Infinity credits" would be printed for a body we cannot actually read. The
|
|
1004
2955
|
// same door the balance read and yin itself use.
|
|
1005
2956
|
if (!readable(n)) throw new Error("账单格式不认识:total_amount 不是数字")
|
|
1006
2957
|
return n
|
|
@@ -1026,11 +2977,7 @@ const yin = (n) => {
|
|
|
1026
2977
|
|
|
1027
2978
|
const sumOf = (records) => yin(records.reduce((n, r) => n + amountOf(r), 0))
|
|
1028
2979
|
|
|
1029
|
-
|
|
1030
|
-
// magpie calls every exported function as its own plugin entry, so the two
|
|
1031
|
-
// exports below are two providers over this one body: same hooks, same
|
|
1032
|
-
// wording, one place for both to drift from.
|
|
1033
|
-
async function pluginFor(profile, input) {
|
|
2980
|
+
export async function MsmoliPlugin(input) {
|
|
1034
2981
|
// input.directory is magpie's own folder (host.go passes settings.Dir()),
|
|
1035
2982
|
// which is where a portable magpie keeps everything too.
|
|
1036
2983
|
const configDir = typeof input?.directory === "string" ? input.directory : ""
|
|
@@ -1041,34 +2988,46 @@ async function pluginFor(profile, input) {
|
|
|
1041
2988
|
|
|
1042
2989
|
return {
|
|
1043
2990
|
auth: {
|
|
1044
|
-
provider:
|
|
2991
|
+
provider: ID,
|
|
1045
2992
|
|
|
1046
|
-
//
|
|
2993
|
+
// A static-token check-in: paste an access token that starts with ms-.
|
|
1047
2994
|
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
//
|
|
1051
|
-
//
|
|
1052
|
-
//
|
|
2995
|
+
// Neither prompts nor authorize is declared: magpie already asks for the
|
|
2996
|
+
// key on its own for an api-type method, and the token is stored as the
|
|
2997
|
+
// key itself. Declaring prompts would write its answer into metadata as
|
|
2998
|
+
// well, putting the same token on disk twice (host.js's save()); and
|
|
2999
|
+
// authorize sees only the answers to prompts, not that key, so it cannot
|
|
3000
|
+
// stand in for it. The placeholder hints at the format; a wrong one shows
|
|
3001
|
+
// as a 401 on the first check-in or usage read.
|
|
1053
3002
|
methods: [
|
|
1054
3003
|
{
|
|
1055
3004
|
type: "api",
|
|
1056
|
-
label: "ModelScope
|
|
3005
|
+
label: "ModelScope 访问令牌(国内站或国际站)",
|
|
1057
3006
|
placeholder: "ms-…",
|
|
1058
3007
|
},
|
|
1059
3008
|
],
|
|
1060
3009
|
|
|
1061
|
-
//
|
|
1062
|
-
//
|
|
3010
|
+
// The check-in: first see whether today's credits have landed, then touch
|
|
3011
|
+
// the login, and finally read the ledger back to reconcile. A direct touch
|
|
3012
|
+
// cannot tell "just claimed" from "claimed long ago"; the ledger can.
|
|
1063
3013
|
//
|
|
1064
|
-
//
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
3014
|
+
// Timing: when asked, if today's target moment for this account has not
|
|
3015
|
+
// arrived yet, answer failed before sending any request — the one outcome
|
|
3016
|
+
// the host retries; any other one stops the day from being asked again.
|
|
3017
|
+
// The cost is that the card reads "check-in failed; magpie will retry
|
|
3018
|
+
// later" during that stretch, the only way the host has today to say "not
|
|
3019
|
+
// yet time" (see the README's upstream section).
|
|
1068
3020
|
async checkin(getAuth) {
|
|
3021
|
+
// The whole hook's budget starts here, not after the host hands the
|
|
3022
|
+
// sign-in over: a store read that waits on another process's lock is
|
|
3023
|
+
// this hook's time too, and 18 seconds of it plus a request timeout
|
|
3024
|
+
// put a 25-second hook past its own limit.
|
|
3025
|
+
const deadline = Date.now() + CHECKIN_BUDGET_MS
|
|
1069
3026
|
let auth, token
|
|
1070
3027
|
try {
|
|
1071
|
-
|
|
3028
|
+
const got = await bounded(Promise.resolve().then(getAuth), deadline - Date.now())
|
|
3029
|
+
if (got === BOUNDED) return { outcome: "failed", message: "读取登录信息超时,稍后自动重试" }
|
|
3030
|
+
auth = got
|
|
1072
3031
|
token = tokenOf(auth)
|
|
1073
3032
|
} catch (e) {
|
|
1074
3033
|
return { outcome: "failed", message: hostText(whyOf(e)) || "签到失败", ...(signedOut(e) ? { signIn: "expired" } : {}) }
|
|
@@ -1080,10 +3039,6 @@ async function pluginFor(profile, input) {
|
|
|
1080
3039
|
// the user to sign in sends them to the wrong place
|
|
1081
3040
|
message: unusableKey(auth) ? "令牌内容无法识别(含空格、换行或非 ASCII 字符),请重新登录" : "没有登录信息,请先登录",
|
|
1082
3041
|
}
|
|
1083
|
-
// the clock the requests run against: a hook that does not finish in
|
|
1084
|
-
// time has failed, as far as the host is concerned, and it asks again
|
|
1085
|
-
const deadline = Date.now() + CHECKIN_BUDGET_MS
|
|
1086
|
-
|
|
1087
3042
|
const today = todayCN()
|
|
1088
3043
|
const win = windowOf(configOf(configDir))
|
|
1089
3044
|
if (win) {
|
|
@@ -1098,7 +3053,7 @@ async function pluginFor(profile, input) {
|
|
|
1098
3053
|
// and it is in the README for whoever wants to change it.
|
|
1099
3054
|
//
|
|
1100
3055
|
// No countdown. The host keeps this line on the card until its
|
|
1101
|
-
// next ask, half an hour later, so "
|
|
3056
|
+
// next ask, half an hour later, so "in about 8 minutes" outlives the
|
|
1102
3057
|
// eight minutes: a real card still read that at 09:47 for a
|
|
1103
3058
|
// 09:28 target whose credits had already landed. The target and
|
|
1104
3059
|
// the cadence are both absolute, and the cadence is also why the
|
|
@@ -1106,23 +3061,52 @@ async function pluginFor(profile, input) {
|
|
|
1106
3061
|
// it runs on the host's next ask, not at the moment it named.
|
|
1107
3062
|
return {
|
|
1108
3063
|
outcome: "failed",
|
|
1109
|
-
|
|
3064
|
+
// the zone is named because only the minute is: the target is
|
|
3065
|
+
// Beijing's whatever zone the host's own clock is in, and the
|
|
3066
|
+
// host shows other times (resets, a window's expiry) in the
|
|
3067
|
+
// machine's zone beside it.
|
|
3068
|
+
message: `${clock(p.target)} 后自动领取(北京时间,magpie 每半小时问一次,无需操作)`,
|
|
1110
3069
|
}
|
|
1111
3070
|
}
|
|
1112
3071
|
}
|
|
1113
3072
|
|
|
3073
|
+
// The site the token was found on is kept on the sign-in, so a
|
|
3074
|
+
// restarted host goes straight there instead of handing the token to
|
|
3075
|
+
// the domestic site for a 401 first. A value that is not one of the
|
|
3076
|
+
// two is ignored and the token probed again.
|
|
3077
|
+
const kept = profileForSite(auth?.site)
|
|
3078
|
+
if (kept && !sites.has(token)) rememberSite(token, kept)
|
|
3079
|
+
|
|
1114
3080
|
// The nickname is written back after the answer is made, not between
|
|
1115
3081
|
// the touch and the reads that reconcile it: magpie's auth.set takes
|
|
1116
3082
|
// plugin-auth.json's lock synchronously (host.js's lockAuth, up to
|
|
1117
3083
|
// twice its 10s stale time when another process holds it), and that
|
|
1118
3084
|
// wait came out of this hook's 25s — a claim that had landed was then
|
|
1119
|
-
// answered "
|
|
3085
|
+
// answered "budget used up" and shown as failed for half an hour.
|
|
3086
|
+
// The write is also capped (WRITE_WAIT_MS) so a locked store cannot
|
|
3087
|
+
// stretch the hook past its budget even from the tail.
|
|
1120
3088
|
let name = ""
|
|
3089
|
+
// the site the reads found the token on, for the write-back after the
|
|
3090
|
+
// answer; null until the first read has answered
|
|
3091
|
+
let probe = null
|
|
3092
|
+
// a confirmed expiry met on a read that is allowed to fail softly
|
|
3093
|
+
// (the name lookup, the streak walk): the day's answer stands, and the
|
|
3094
|
+
// host still has to hear that the token was refused
|
|
3095
|
+
const marks = { expired: false }
|
|
1121
3096
|
const answer = await (async () => {
|
|
1122
3097
|
try {
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
3098
|
+
let before = await todayEarn(token, deadline, { expiry: false })
|
|
3099
|
+
let rolls = 0
|
|
3100
|
+
for (;;) {
|
|
3101
|
+
const profile = before.profile
|
|
3102
|
+
probe = profile
|
|
3103
|
+
const beforeDaily = before.rows.filter((r) => DAILY_RULES.includes(r?.rule_key))
|
|
3104
|
+
const claimed = before.rows.some((r) => r?.rule_key === "daily_active")
|
|
3105
|
+
// A baseline that was not read to the end cannot prove the day's
|
|
3106
|
+
// grant absent: the row may sit on the page that failed, and the
|
|
3107
|
+
// touch-then-diff would report a row nobody read as this run's gain.
|
|
3108
|
+
// `failed` is the one outcome the host retries.
|
|
3109
|
+
if (!before.complete && !claimed) return { outcome: "failed", message: "账单读取不完整,稍后自动重试" }
|
|
1126
3110
|
// What this run granted is the ledger's growth, not a new key: the
|
|
1127
3111
|
// rows are aggregated per (day, type, rule_key, model_tier), so a
|
|
1128
3112
|
// second grant of a rule already in today's page grows that row's
|
|
@@ -1130,11 +3114,21 @@ async function pluginFor(profile, input) {
|
|
|
1130
3114
|
// aliyun_bindlogin (from the website, say) and gains nothing from
|
|
1131
3115
|
// the touch must not report "claimed 50" — and one that gains more
|
|
1132
3116
|
// of a rule it already had must still report it.
|
|
3117
|
+
// Only the daily-rule rows go into the comparison. The ledger also
|
|
3118
|
+
// holds EARN rows of other rules — promotions, bind bonuses — and a
|
|
3119
|
+
// row of one that carries no amount (a server-side adjustment, a
|
|
3120
|
+
// rule added since) would throw here and answer `failed` for a day
|
|
3121
|
+
// whose claim had already landed. Those rows are not what this
|
|
3122
|
+
// comparison is about, so their amounts are never read. It is the
|
|
3123
|
+
// same rule the usage card follows: only the kinds it sums need an
|
|
3124
|
+
// amount.
|
|
1133
3125
|
const byRule = (rows) => {
|
|
1134
3126
|
const m = new Map()
|
|
1135
3127
|
for (const r of rows) m.set(r?.rule_key, (m.get(r?.rule_key) ?? 0) + amountOf(r))
|
|
1136
3128
|
return m
|
|
1137
3129
|
}
|
|
3130
|
+
// `beforeDaily` and every `rows` handed to grew(...) are already
|
|
3131
|
+
// filtered to DAILY_RULES by the caller.
|
|
1138
3132
|
const beforeByRule = byRule(beforeDaily)
|
|
1139
3133
|
// The name a rule is shown by: the first row of that key that carries
|
|
1140
3134
|
// one. Rows aggregate per (day, type, rule_key, model_tier), so a key
|
|
@@ -1144,11 +3138,15 @@ async function pluginFor(profile, input) {
|
|
|
1144
3138
|
// "+50" with nothing in front of it.
|
|
1145
3139
|
const titleOf = (rows, key) =>
|
|
1146
3140
|
rows.find((r) => r?.rule_key === key && typeof r.title === "string" && r.title.trim())?.title
|
|
1147
|
-
const grew = (rows) => {
|
|
3141
|
+
const grew = (rows, pageDay) => {
|
|
1148
3142
|
const after = byRule(rows)
|
|
3143
|
+
// The rows of a new Beijing day cannot be diffed against the day
|
|
3144
|
+
// before it: a grant that landed at 00:00:xx on the 50 already in
|
|
3145
|
+
// yesterday's page would net to zero.
|
|
3146
|
+
const base = pageDay === before.day ? beforeByRule : new Map()
|
|
1149
3147
|
const out = []
|
|
1150
3148
|
for (const [key, sum] of after) {
|
|
1151
|
-
const gain = yin(sum - (
|
|
3149
|
+
const gain = yin(sum - (base.get(key) ?? 0))
|
|
1152
3150
|
if (gain > 0) out.push({ key, gain, title: titleOf(rows, key) })
|
|
1153
3151
|
}
|
|
1154
3152
|
return out
|
|
@@ -1158,13 +3156,46 @@ async function pluginFor(profile, input) {
|
|
|
1158
3156
|
// left to do. magpie asks a `done` again every half hour before noon
|
|
1159
3157
|
// when it has not seen the claim itself (provisional), and each ask
|
|
1160
3158
|
// touching login/info was one more recorded sign-in — up to a dozen
|
|
1161
|
-
// a morning for an account claimed on the website.
|
|
1162
|
-
|
|
3159
|
+
// a morning for an account claimed on the website. "Known" includes
|
|
3160
|
+
// a remembered "the server gave no usable name" (`names` holds the
|
|
3161
|
+
// empty string then), or the touch would repeat for exactly the
|
|
3162
|
+
// accounts whose nickname is unusable.
|
|
3163
|
+
const known = names.has(token)
|
|
1163
3164
|
if (claimed && known) {
|
|
1164
|
-
name =
|
|
3165
|
+
name = names.get(token)
|
|
3166
|
+
} else if (claimed) {
|
|
3167
|
+
// The grant is already in the ledger, so this read is only for the
|
|
3168
|
+
// card's name. A refusal or a broken answer must not turn the day
|
|
3169
|
+
// into `failed`: the host retries a failed day every half hour,
|
|
3170
|
+
// touching login/info each time, for a claim that already landed.
|
|
3171
|
+
name = ""
|
|
3172
|
+
try {
|
|
3173
|
+
const { res, body, parsed } = await json(profile.site, token, LOGIN_INFO, deadline)
|
|
3174
|
+
if (res.status === 401 && parsed && awaitsSignIn(body)) throw deadToken(profile.where)
|
|
3175
|
+
gate(profile, res, body, parsed, token, "登录接口失败", `${RISK},稍后会自动重试`)
|
|
3176
|
+
const me = dataOf(body)
|
|
3177
|
+
// A name is a string. Anything else — a number, an object, a list —
|
|
3178
|
+
// would reach text() as "12345" or "[object Object]" and be written
|
|
3179
|
+
// back as the account's name, and magpie's card would show that
|
|
3180
|
+
// instead of the key's tail. NickName wins when it is a name; Name
|
|
3181
|
+
// is the fallback it always was.
|
|
3182
|
+
const str = (v) => (typeof v === "string" && v.trim() ? v : "")
|
|
3183
|
+
const raw = str(me.NickName) || str(me.Name)
|
|
3184
|
+
const candidate = text(raw, 40)
|
|
3185
|
+
// judged whole, then cut: the cut can leave a piece of the token
|
|
3186
|
+
// in the name that neither the cut string nor the whole one
|
|
3187
|
+
// answers for alone (see runsThrough)
|
|
3188
|
+
name = unusableName(raw, token) || unusableName(candidate, token) ? "" : candidate
|
|
3189
|
+
rememberName(token, name)
|
|
3190
|
+
} catch (e) {
|
|
3191
|
+
// no name this ask; the day still stands as done — but a 401 the
|
|
3192
|
+
// gate confirmed is the token refused, and the host is told
|
|
3193
|
+
if (signedOut(e)) marks.expired = true
|
|
3194
|
+
}
|
|
1165
3195
|
} else {
|
|
1166
3196
|
const { res, body, parsed } = await json(profile.site, token, LOGIN_INFO, deadline)
|
|
1167
|
-
// 403
|
|
3197
|
+
// A 403 is risk control, not a bad token: the account may be fine and
|
|
3198
|
+
// only this request was refused.
|
|
1168
3199
|
gate(profile, res, body, parsed, token, "登录接口失败", `${RISK},稍后会自动重试`)
|
|
1169
3200
|
|
|
1170
3201
|
const me = dataOf(body)
|
|
@@ -1174,30 +3205,17 @@ async function pluginFor(profile, input) {
|
|
|
1174
3205
|
// instead of the key's tail. NickName wins when it is a name; Name
|
|
1175
3206
|
// is the fallback it always was.
|
|
1176
3207
|
const str = (v) => (typeof v === "string" && v.trim() ? v : "")
|
|
1177
|
-
const
|
|
1178
|
-
|
|
3208
|
+
const raw = str(me.NickName) || str(me.Name)
|
|
3209
|
+
const candidate = text(raw, 40)
|
|
3210
|
+
// judged whole, then cut: the cut can leave a piece of the token
|
|
3211
|
+
// in the name that neither the cut string nor the whole one
|
|
3212
|
+
// answers for alone (see runsThrough)
|
|
3213
|
+
name = unusableName(raw, token) || unusableName(candidate, token) ? "" : candidate
|
|
1179
3214
|
rememberName(token, name)
|
|
1180
3215
|
}
|
|
1181
3216
|
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
// 下一页、或当日合计被重算),卡片会显示「今日已领取 0 魔粒」。
|
|
1185
|
-
if (claimed) {
|
|
1186
|
-
const credit = sumOf(beforeDaily)
|
|
1187
|
-
const which = beforeDaily.map((r) => cardText(r.title, token, 40)).filter(Boolean).join(" + ")
|
|
1188
|
-
// An EARN row can carry a reversal (冲正), and a day whose grants
|
|
1189
|
-
// were reversed nets below zero. magpie clamps the numeric credit
|
|
1190
|
-
// itself (host.js's num: Math.max(0, v)) but renders `message`
|
|
1191
|
-
// verbatim, so "今日已领取 -50 魔粒" would reach the card: a claim of
|
|
1192
|
-
// a negative amount is not a thing. The rows are still named — the
|
|
1193
|
-
// user can see which ones — but the amount stops at zero, the way
|
|
1194
|
-
// the usage card's limit already does.
|
|
1195
|
-
const shown = Math.max(0, credit)
|
|
1196
|
-
return { outcome: "done", credit: shown, message: `今日已领取 ${shown} 魔粒` + (which ? `(${which})` : "") }
|
|
1197
|
-
}
|
|
1198
|
-
|
|
1199
|
-
const gained = (rows) => {
|
|
1200
|
-
const grew1 = grew(rows)
|
|
3217
|
+
const gained = (rows, pageDay) => {
|
|
3218
|
+
const grew1 = grew(rows, pageDay)
|
|
1201
3219
|
if (!grew1.length) return null
|
|
1202
3220
|
const credit = yin(grew1.reduce((n, g) => n + g.gain, 0))
|
|
1203
3221
|
// A key with no name on any of its rows is shown by its amount
|
|
@@ -1212,18 +3230,128 @@ async function pluginFor(profile, input) {
|
|
|
1212
3230
|
return { credit, which }
|
|
1213
3231
|
}
|
|
1214
3232
|
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
//
|
|
1218
|
-
//
|
|
1219
|
-
//
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
3233
|
+
// The name read above can sit across Beijing midnight: the baseline
|
|
3234
|
+
// is then yesterday's, and answering `done` for it would settle the
|
|
3235
|
+
// new day without claiming it. A day that changed while the hook ran
|
|
3236
|
+
// is re-read and the walk starts over for the new day (twice at
|
|
3237
|
+
// most, so a clock that keeps rolling cannot loop the hook).
|
|
3238
|
+
if (todayCN() !== before.day && rolls < 2) {
|
|
3239
|
+
rolls += 1
|
|
3240
|
+
const wasDay = before.day
|
|
3241
|
+
before = await todayEarn(token, deadline, { expiry: false })
|
|
3242
|
+
probe = before.profile
|
|
3243
|
+
// The touch was this run's, and the day changed under it: the
|
|
3244
|
+
// baseline is yesterday's, and `gained` reads an empty one for a
|
|
3245
|
+
// page whose day is not the one it is handed. So the new day's
|
|
3246
|
+
// whole page is the diff — every daily grant on it was written
|
|
3247
|
+
// between the two reads (the page had nothing for the new day when
|
|
3248
|
+
// the baseline was read). A grant the server stamped with
|
|
3249
|
+
// yesterday's date (a late write) is not on this page at all, and
|
|
3250
|
+
// is not claimed here: it belongs to yesterday's answer, which
|
|
3251
|
+
// this run already gave.
|
|
3252
|
+
const bornToday = gained(before.rows.filter((r) => DAILY_RULES.includes(r?.rule_key)), wasDay)
|
|
3253
|
+
if (bornToday) {
|
|
3254
|
+
return withStreak({
|
|
3255
|
+
outcome: "claimed",
|
|
3256
|
+
credit: bornToday.credit,
|
|
3257
|
+
message: `领取 ${bornToday.credit} 魔粒:${bornToday.which}` + (before.complete ? "" : ";账单未读完,数字偏低"),
|
|
3258
|
+
...(marks.expired ? { signIn: "expired" } : {}),
|
|
3259
|
+
}, await streakOf(before.all, before.day, before.total, ledgerReader(profile, token, deadline, before.pages, before.last, before.seenPages, before.seenRows, marks), deadline), marks)
|
|
3260
|
+
}
|
|
3261
|
+
if (!before.complete) {
|
|
3262
|
+
return { outcome: "failed", message: "账单读取不完整,稍后自动重试", ...(marks.expired ? { signIn: "expired" } : {}) }
|
|
3263
|
+
}
|
|
3264
|
+
continue
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
// A done uses the totals from the page **before** the touch: if today
|
|
3268
|
+
// was already claimed, what was claimed is the number the ledger held
|
|
3269
|
+
// then. From the page after the touch, a ledger that changed between
|
|
3270
|
+
// the two reads (rows pushed to the next page, or the day's total
|
|
3271
|
+
// recomputed) would make the card say today's claim is 0 credits.
|
|
3272
|
+
if (claimed) {
|
|
3273
|
+
const credit = sumOf(beforeDaily)
|
|
3274
|
+
// each title is cut and stringified as before, and the join is
|
|
3275
|
+
// masked once at the end: two titles that each carry a piece of the
|
|
3276
|
+
// key under MIN_ECHO spell it together, and a per-title mask never
|
|
3277
|
+
// sees the pair.
|
|
3278
|
+
const joined = beforeDaily
|
|
3279
|
+
.map((r) => cardText(r.title, token, 40))
|
|
3280
|
+
.filter(Boolean)
|
|
3281
|
+
.join(" + ")
|
|
3282
|
+
const which = cardText(joined, token, 200)
|
|
3283
|
+
// An EARN row can carry a reversal, and a day whose grants were
|
|
3284
|
+
// reversed nets below zero. magpie clamps the numeric credit
|
|
3285
|
+
// itself (host.js's num: Math.max(0, v)) but renders `message`
|
|
3286
|
+
// verbatim, so a negative claimed-today line would reach the card:
|
|
3287
|
+
// a claim of a negative amount is not a thing. The rows are still
|
|
3288
|
+
// named — the user can see which ones — but the amount stops at
|
|
3289
|
+
// zero, the way the usage card's limit already does.
|
|
3290
|
+
const shown = Math.max(0, credit)
|
|
3291
|
+
// The streak is read from the page **before** the touch here too:
|
|
3292
|
+
// the touch grants nothing new for an account already claimed, so
|
|
3293
|
+
// the two readings answer the same thing, and this one is the page
|
|
3294
|
+
// the totals above were taken from.
|
|
3295
|
+
return withStreak({
|
|
3296
|
+
outcome: "done",
|
|
3297
|
+
credit: shown,
|
|
3298
|
+
message: `今日已领取 ${shown} 魔粒` + (which ? `(${which})` : "") + (before.complete ? "" : ";账单未读完,数字偏低"),
|
|
3299
|
+
...(marks.expired ? { signIn: "expired" } : {}),
|
|
3300
|
+
}, await streakOf(before.all, before.day, before.total, ledgerReader(profile, token, deadline, before.pages, before.last, before.seenPages, before.seenRows, marks), deadline), marks)
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
// Both claimed exits answer the same way; the streak is computed in
|
|
3304
|
+
// one place so a change to it cannot land in one branch only.
|
|
3305
|
+
const claimedNow = async (page, gain) => {
|
|
3306
|
+
const streak = await streakOf(page.all, page.day, page.total, ledgerReader(profile, token, deadline, page.pages, page.last, page.seenPages, page.seenRows, marks), deadline)
|
|
3307
|
+
return withStreak({
|
|
3308
|
+
outcome: "claimed",
|
|
3309
|
+
credit: gain.credit,
|
|
3310
|
+
message: `领取 ${gain.credit} 魔粒:${gain.which}` + (page.complete ? "" : ";账单未读完,数字偏低"),
|
|
3311
|
+
...(marks.expired ? { signIn: "expired" } : {}),
|
|
3312
|
+
}, streak, marks)
|
|
3313
|
+
}
|
|
3314
|
+
|
|
3315
|
+
const fresh = await todayEarn(token, deadline, { expiry: false })
|
|
3316
|
+
probe = fresh.profile
|
|
3317
|
+
const daily = gained(fresh.rows.filter((r) => DAILY_RULES.includes(r?.rule_key)), fresh.day)
|
|
3318
|
+
if (daily) return await claimedNow(fresh, daily)
|
|
3319
|
+
// Touched, yet no credit record: the ledger may not be written yet
|
|
3320
|
+
// (grants are asynchronous), or this account may simply not be in the
|
|
3321
|
+
// campaign. Read the ledger once more to confirm, and do not drop a
|
|
3322
|
+
// grant that was just written. This re-read buys no "wait a while" —
|
|
3323
|
+
// if the grant really is written a few seconds later, the immediate
|
|
3324
|
+
// re-read cannot see it either. Before Beijing noon the host covers
|
|
3325
|
+
// that stretch: a done/inactive that has not seen a claim is a
|
|
3326
|
+
// provisional answer, re-asked every 30 minutes (provisional, decided
|
|
3327
|
+
// by checkinSureHour). From noon the host settles the day, so in the
|
|
3328
|
+
// afternoon the plugin asks for one more ask half an hour later itself,
|
|
3329
|
+
// via until.
|
|
3330
|
+
const again = await todayEarn(token, deadline, { expiry: false })
|
|
3331
|
+
probe = again.profile
|
|
3332
|
+
const later = gained(again.rows.filter((r) => DAILY_RULES.includes(r?.rule_key)), again.day)
|
|
3333
|
+
if (later) return await claimedNow(again, later)
|
|
3334
|
+
// A ledger this walk could not finish is not evidence that nothing
|
|
3335
|
+
// arrived: the grant may sit on the page that failed, and `inactive`
|
|
3336
|
+
// without `until` settles the day on that. The last read is the one
|
|
3337
|
+
// that decides — an earlier incomplete read is answered by this one.
|
|
3338
|
+
// The touch has happened, so the answer that costs nothing is
|
|
3339
|
+
// `failed` — the one outcome the host retries, which reads the
|
|
3340
|
+
// ledger again half an hour later.
|
|
3341
|
+
if (!again.complete) {
|
|
3342
|
+
return { outcome: "failed", message: "账单读取不完整,稍后自动重试" }
|
|
3343
|
+
}
|
|
1226
3344
|
const hourCN = Math.floor(minuteCN() / 60)
|
|
3345
|
+
// Before noon the host re-asks a provisional answer every half hour,
|
|
3346
|
+
// so an `inactive` here is safe: a grant that is simply late is read
|
|
3347
|
+
// again, and the repeat past noon gets the `until` below. From noon
|
|
3348
|
+
// the host settles the day, so the first `inactive` from noon on
|
|
3349
|
+
// carries `until`, once per day. A check-in that lands just after
|
|
3350
|
+
// midnight is before SURE_HOUR and already covered by the host's
|
|
3351
|
+
// provisional re-ask — that is the guard, and it is the hour, not a
|
|
3352
|
+
// stale day flag: `deferred` holds the Beijing day the `until` was
|
|
3353
|
+
// given on, and a new day starts with no flag, so the afternoon of
|
|
3354
|
+
// the new day gets its own `until` like any other day.
|
|
1227
3355
|
if (hourCN >= SURE_HOUR && deferred.get(token) !== today) {
|
|
1228
3356
|
for (const [k, d] of deferred) if (d !== today) deferred.delete(k)
|
|
1229
3357
|
deferred.set(token, today)
|
|
@@ -1233,7 +3361,15 @@ async function pluginFor(profile, input) {
|
|
|
1233
3361
|
until: new Date(Date.now() + AGAIN_MS).toISOString(),
|
|
1234
3362
|
}
|
|
1235
3363
|
}
|
|
3364
|
+
if (beforeDaily.length) {
|
|
3365
|
+
// no until: this answer settles the day, so the line must not
|
|
3366
|
+
// promise a re-check the host will not make. Today has credits of
|
|
3367
|
+
// other kinds and no check-in reward, which reads as an account
|
|
3368
|
+
// outside the activity rather than a grant still on its way.
|
|
3369
|
+
return { outcome: "inactive", message: "登录已触达,今天有到账但没有签到奖励(账号可能不在活动范围)" }
|
|
3370
|
+
}
|
|
1236
3371
|
return { outcome: "inactive", message: "登录已触达,但今天没有到账记录(账号可能不在活动范围)" }
|
|
3372
|
+
}
|
|
1237
3373
|
} catch (e) {
|
|
1238
3374
|
return { outcome: "failed", message: cardText(whyOf(e), token) || "签到失败", ...(signedOut(e) ? { signIn: "expired" } : {}) }
|
|
1239
3375
|
}
|
|
@@ -1242,37 +3378,50 @@ async function pluginFor(profile, input) {
|
|
|
1242
3378
|
// catches its own failures, and anything it could still throw (an
|
|
1243
3379
|
// unreadable providers.json read) must not undo the answer above
|
|
1244
3380
|
try {
|
|
1245
|
-
await
|
|
3381
|
+
await rememberAccountFields(client, token, { name, profile: probe, probeDone: !!probe }, auth, configDir, deadline, getAuth)
|
|
1246
3382
|
} catch {}
|
|
1247
3383
|
return answer
|
|
1248
3384
|
},
|
|
1249
3385
|
|
|
1250
|
-
//
|
|
3386
|
+
// The credits balance and today's usage, for the account card.
|
|
1251
3387
|
async usage(getAuth) {
|
|
1252
3388
|
// everything the host could get wrong about the hand-over is the
|
|
1253
3389
|
// card's problem alone: it must come back as {error}, never as a
|
|
1254
3390
|
// rejection out of the hook
|
|
3391
|
+
const deadline = Date.now() + USAGE_BUDGET_MS
|
|
1255
3392
|
let auth, token
|
|
1256
3393
|
try {
|
|
1257
|
-
|
|
3394
|
+
const got = await bounded(Promise.resolve().then(getAuth), deadline - Date.now())
|
|
3395
|
+
if (got === BOUNDED) return { error: "读取登录信息超时,稍后自动重试" }
|
|
3396
|
+
auth = got
|
|
1258
3397
|
token = tokenOf(auth)
|
|
1259
3398
|
} catch (e) {
|
|
1260
3399
|
return { error: hostText(whyOf(e)) || "用量查询失败", signIn: signedOut(e) ? "expired" : "" }
|
|
1261
3400
|
}
|
|
1262
3401
|
if (!token) return { error: unusableKey(auth) ? "令牌内容无法识别,请重新登录" : "没有登录信息", signIn: "" }
|
|
1263
|
-
|
|
3402
|
+
// the same seeding as the check-in's: a site kept on the sign-in saves
|
|
3403
|
+
// the domestic-site 401 a restarted host would otherwise pay first
|
|
3404
|
+
const kept = profileForSite(auth?.site)
|
|
3405
|
+
if (kept && !sites.has(token)) rememberSite(token, kept)
|
|
1264
3406
|
// the name is only known from login/info, which usage must never
|
|
1265
|
-
// reach; a check-in in this process leaves it here for the card
|
|
1266
|
-
//
|
|
1267
|
-
//
|
|
3407
|
+
// reach; a check-in in this process leaves it here for the card, and
|
|
3408
|
+
// the write-back of a name the check-in failed to write is retried
|
|
3409
|
+
// here. That write is the last thing before the answer, not the first
|
|
3410
|
+
// thing before the reads: the host's auth.set can wait out a lock for
|
|
3411
|
+
// ~20s (see the check-in's own note), and a wait spent before the
|
|
3412
|
+
// reads came out of the 18s the two of them share — the card answered
|
|
3413
|
+
// "budget used up" for a name it had just written. Everything here is inside
|
|
3414
|
+
// the try, so the hook still promises an {error} and never a
|
|
3415
|
+
// rejection, whatever that write or the reads do.
|
|
1268
3416
|
try {
|
|
1269
|
-
|
|
1270
|
-
const
|
|
3417
|
+
const bal = await siteRead(token, BALANCE, deadline)
|
|
3418
|
+
const profile = bal.profile
|
|
1271
3419
|
gate(profile, bal.res, bal.body, bal.parsed, token, "余额查询失败")
|
|
1272
|
-
// data
|
|
1273
|
-
//
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
3420
|
+
// A missing data read as 0 would turn "did not understand" into a
|
|
3421
|
+
// definite and wrong conclusion, "balance is zero"; the card would
|
|
3422
|
+
// read "0 credits available". recordsOf on the ledger side is the
|
|
3423
|
+
// same. The field layer is too: available_balance absent, null, or
|
|
3424
|
+
// renamed is not a zero balance.
|
|
1276
3425
|
const balData = dataObjectOf(bal.body, "余额查询失败:响应里没有 data")
|
|
1277
3426
|
const rawAvailable = balData.available_balance
|
|
1278
3427
|
if (blankOf(rawAvailable)) {
|
|
@@ -1280,27 +3429,28 @@ async function pluginFor(profile, input) {
|
|
|
1280
3429
|
}
|
|
1281
3430
|
// A number or a numeric string is a balance. Anything else is not:
|
|
1282
3431
|
// Number([]) is 0 and Number(true) is 1, both finite, so the plain
|
|
1283
|
-
// isFinite check let an empty list through as "0
|
|
3432
|
+
// isFinite check let an empty list through as "0 credits available" — the
|
|
1284
3433
|
// definite wrong answer this guard exists to stop — and a boolean
|
|
1285
|
-
// through as one
|
|
3434
|
+
// through as one credit. A formatted string ("1,234") is a number we
|
|
1286
3435
|
// cannot read, not 0, and blankOf covers the field that holds
|
|
1287
3436
|
// nothing but spaces, which `Number` also reads as 0.
|
|
1288
3437
|
const kind = typeof rawAvailable
|
|
1289
3438
|
if (kind !== "number" && kind !== "string") throw new Error("余额查询失败:available_balance 不是数字")
|
|
1290
3439
|
const availableN = Number(rawAvailable)
|
|
1291
3440
|
if (!readable(availableN)) throw new Error("余额查询失败:available_balance 不是数字")
|
|
1292
|
-
// a balance is not a debt: the server's
|
|
1293
|
-
// below zero, and "-5
|
|
3441
|
+
// a balance is not a debt: the server's reversal direction can put it
|
|
3442
|
+
// below zero, and "-5 credits available" is not a thing the card can show
|
|
1294
3443
|
const available = Math.max(0, yin(availableN))
|
|
1295
3444
|
|
|
1296
3445
|
const tx = await json(profile.site, token, `${TXNS}?page=1&page_size=${PAGE_SIZE}`, deadline)
|
|
1297
3446
|
gate(profile, tx.res, tx.body, tx.parsed, token, "账单查询失败")
|
|
1298
3447
|
|
|
1299
3448
|
const day = todayCN()
|
|
3449
|
+
const walked = await dayRows(profile, token, day, recordsOf(tx.body), deadline, tx.body)
|
|
1300
3450
|
let earned = 0
|
|
1301
3451
|
let spent = 0
|
|
1302
3452
|
let earliest = ""
|
|
1303
|
-
for (const r of
|
|
3453
|
+
for (const r of walked.all) {
|
|
1304
3454
|
// Only the two kinds that go into the card's numbers need an
|
|
1305
3455
|
// amount. A row of a kind we do not sum is not counted either
|
|
1306
3456
|
// way, and failing the whole card over its amount would turn a
|
|
@@ -1308,10 +3458,13 @@ async function pluginFor(profile, input) {
|
|
|
1308
3458
|
// numbers at all" — the opposite of what the rule is for.
|
|
1309
3459
|
if (onDay(r?.gmt_created, day)) {
|
|
1310
3460
|
if (r?.type === "EARN") {
|
|
1311
|
-
//
|
|
1312
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
3461
|
+
// "Claimed" means everything credited today, promotions
|
|
3462
|
+
// included: the card asks "how much came in, how much was
|
|
3463
|
+
// used", and excluding promotions would show "claimed 0" while
|
|
3464
|
+
// the balance rose by 500. The credit the check-in reports is a
|
|
3465
|
+
// different question (how much this check-in claimed), counting
|
|
3466
|
+
// only the daily grants; the two numbers differing is each
|
|
3467
|
+
// giving its own answer, not two ways of computing one number.
|
|
1315
3468
|
earned += amountOf(r)
|
|
1316
3469
|
} else if (r?.type === "SPEND_CONFIRM") {
|
|
1317
3470
|
spent += amountOf(r)
|
|
@@ -1330,36 +3483,47 @@ async function pluginFor(profile, input) {
|
|
|
1330
3483
|
}
|
|
1331
3484
|
}
|
|
1332
3485
|
|
|
1333
|
-
//
|
|
1334
|
-
//
|
|
1335
|
-
//
|
|
1336
|
-
//
|
|
1337
|
-
//
|
|
3486
|
+
// Not truncated at 100: magpie reads used > 100 as "overspent"
|
|
3487
|
+
// (host.js's "past 100 when overspent"). Today's credits being spent
|
|
3488
|
+
// up means nothing was claimed today and the balance is zero. The
|
|
3489
|
+
// lower bound is clamped to 0: refunds in the ledger are negative
|
|
3490
|
+
// SPEND_CONFIRM rows, and without the clamp the card would show a
|
|
3491
|
+
// "used -50 today / claimed 200" combination and a negative
|
|
3492
|
+
// percentage — the host clamps it itself; this is one more safety net.
|
|
1338
3493
|
// summed in floating point: 0.1 + 0.2 is 0.30000000000000004, and it
|
|
1339
3494
|
// would go into the card as written
|
|
1340
3495
|
earned = yin(earned)
|
|
1341
3496
|
spent = yin(spent)
|
|
1342
|
-
//
|
|
1343
|
-
//
|
|
3497
|
+
// The credit direction can also come out negative (reversals,
|
|
3498
|
+
// clawbacks). Without the clamp the card would write "claimed -50",
|
|
3499
|
+
// and a negative limit makes even less sense. Same for the spend
|
|
3500
|
+
// direction.
|
|
1344
3501
|
const earnedToday = Math.max(0, earned)
|
|
1345
3502
|
const usedToday = Math.max(0, spent)
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
//
|
|
3503
|
+
// When there is no credit today, the percentage's denominator becomes
|
|
3504
|
+
// "how much is in hand today" — what was used plus what remains. When
|
|
3505
|
+
// that denominator is 0 the old measure stands: only a depleted
|
|
3506
|
+
// balance counts as spent up. Otherwise the card would show the
|
|
3507
|
+
// combination used=0, used 348.5 today, claimed 0 — the progress bar
|
|
3508
|
+
// says nothing moved while the text says a lot was spent.
|
|
1350
3509
|
//
|
|
1351
|
-
//
|
|
1352
|
-
// Infinity
|
|
1353
|
-
//
|
|
1354
|
-
//
|
|
3510
|
+
// Both numbers in the ledger passed readable(), yet the ratio can be
|
|
3511
|
+
// large enough to overflow to Infinity at ×10000; the host reads a
|
|
3512
|
+
// non-finite used as 0 (host.js's num accepts only finite numbers),
|
|
3513
|
+
// and so "almost all used up" was drawn as nothing having moved. When
|
|
3514
|
+
// the percentage cannot be computed it is handed to the host as "not
|
|
3515
|
+
// said" (used omitted = null), and the text line still states the
|
|
3516
|
+
// real amounts.
|
|
1355
3517
|
const pct = (n, d) => {
|
|
1356
3518
|
const v = Math.round((n / d) * 10000) / 100
|
|
1357
3519
|
return Number.isFinite(v) ? v : null
|
|
1358
3520
|
}
|
|
1359
|
-
//
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
3521
|
+
// More used than claimed today, yet a balance remains: what was spent
|
|
3522
|
+
// is yesterday's not-yet-expired credits, not an overage. The
|
|
3523
|
+
// denominator switches to "how much is in hand today" then too —
|
|
3524
|
+
// otherwise, 250 claimed yesterday at 15:00, 250 claimed today, 300
|
|
3525
|
+
// used this morning, the card would draw a 120% overage beside a
|
|
3526
|
+
// balance of 200. An overage holds only when the balance is empty too.
|
|
1363
3527
|
const used =
|
|
1364
3528
|
earnedToday > 0 && (usedToday <= earnedToday || available === 0)
|
|
1365
3529
|
? pct(usedToday, earnedToday)
|
|
@@ -1367,21 +3531,49 @@ async function pluginFor(profile, input) {
|
|
|
1367
3531
|
? pct(usedToday, usedToday + available)
|
|
1368
3532
|
: 100
|
|
1369
3533
|
|
|
1370
|
-
//
|
|
1371
|
-
//
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
3534
|
+
// Before today's credits are claimed, put the window's target moment
|
|
3535
|
+
// on the card, so "claimed 0" does not look like something went wrong.
|
|
3536
|
+
//
|
|
3537
|
+
// The config is read only when the card actually needs the target:
|
|
3538
|
+
// after the day's grant has landed there is no hint to compute, and
|
|
3539
|
+
// this file read is the hook's only synchronous I/O — a poll every
|
|
3540
|
+
// few minutes per account should not pay for it to be thrown away.
|
|
3541
|
+
// `win` is null when earnedToday is non-zero, so `ahead` is null
|
|
3542
|
+
// exactly where `win && earnedToday === 0` used to make it so.
|
|
3543
|
+
const win = earnedToday === 0 ? windowOf(configOf(configDir)) : null
|
|
3544
|
+
const ahead = win ? plan(win, token, day) : null
|
|
3545
|
+
// the minute is Beijing's (minuteCN), while the card's reset time is
|
|
3546
|
+
// the machine's zone: say which one this is, or a reader elsewhere
|
|
3547
|
+
// takes it for their own clock.
|
|
3548
|
+
const hint = ahead && !ahead.due ? ` · ${clock(ahead.target)} 领取(北京时间)` : ""
|
|
3549
|
+
// A walk that could not finish leaves the two numbers a floor: the
|
|
3550
|
+
// ledger contradicted itself, or a page failed. Said on the card, so
|
|
3551
|
+
// they are not read as the day's whole truth.
|
|
3552
|
+
// the countdown's own scan can be cut short while the numbers are
|
|
3553
|
+
// whole (the page cap, the budget): then the earliest expiry seen is
|
|
3554
|
+
// a floor, not the answer, and the card says so rather than naming a
|
|
3555
|
+
// later time as if it were settled
|
|
3556
|
+
const part = (walked.complete ? "" : " · 账单未读完,数字偏低") + (walked.scanComplete || !earliest ? "" : " · 到期时间可能更早")
|
|
3557
|
+
|
|
3558
|
+
// last, and awaited: the card must not be answered ahead of the
|
|
3559
|
+
// write (a poll answered before it would race the very retry this
|
|
3560
|
+
// exists for), while the reads above have already had the budget to
|
|
3561
|
+
// themselves.
|
|
3562
|
+
await rememberAccountFields(client, token, { name: names.get(token) ?? "", profile, probeDone: true }, auth, configDir, deadline, getAuth)
|
|
1375
3563
|
|
|
1376
3564
|
return {
|
|
1377
3565
|
plan: profile.plan,
|
|
1378
|
-
user: names.get(token) ?? "",
|
|
3566
|
+
user: nameText(names.get(token) ?? "", token),
|
|
1379
3567
|
balance: `${available} 魔粒可用`,
|
|
3568
|
+
// a token refused on a courtesy page is refused: the numbers that
|
|
3569
|
+
// page was read for stand, and the card says what the next poll
|
|
3570
|
+
// would say anyway — the account needs signing in again
|
|
3571
|
+
...(walked.refused ? { signIn: "expired" } : {}),
|
|
1380
3572
|
windows: [
|
|
1381
3573
|
{
|
|
1382
3574
|
name: "魔粒",
|
|
1383
3575
|
used,
|
|
1384
|
-
display: `今日用 ${usedToday} / 领 ${earnedToday} · 余额 ${available}${hint}`,
|
|
3576
|
+
display: `今日用 ${usedToday} / 领 ${earnedToday} · 余额 ${available}${hint}${part}`,
|
|
1385
3577
|
amount: usedToday,
|
|
1386
3578
|
limit: earnedToday,
|
|
1387
3579
|
unit: "魔粒",
|
|
@@ -1396,45 +3588,37 @@ async function pluginFor(profile, input) {
|
|
|
1396
3588
|
},
|
|
1397
3589
|
},
|
|
1398
3590
|
|
|
1399
|
-
//
|
|
1400
|
-
// API
|
|
3591
|
+
// Make the provider show up by name without registering any model: chat
|
|
3592
|
+
// still goes through the API-key modelscope preset in providers.json.
|
|
1401
3593
|
async config(config) {
|
|
1402
|
-
//
|
|
1403
|
-
//
|
|
1404
|
-
//
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
1407
|
-
//
|
|
3594
|
+
// The host builds `{ provider: {}, ...userConfig }`, and the provider in
|
|
3595
|
+
// the user config overrides the default {}. When that is not an object (a
|
|
3596
|
+
// string an old version wrote, a hand-edited number), the `[profile.id] =`
|
|
3597
|
+
// below throws a TypeError, and the host only logs the config hook's
|
|
3598
|
+
// error — the plugin still appears, just without a name, and there is no
|
|
3599
|
+
// sign of what went wrong. `??=` guards only null/undefined, not "", 0, or
|
|
3600
|
+
// false; a container that cannot hold an id has nothing worth keeping, so
|
|
3601
|
+
// the whole thing is replaced.
|
|
1408
3602
|
if (!config.provider || typeof config.provider !== "object") config.provider = {}
|
|
1409
|
-
//
|
|
1410
|
-
//
|
|
1411
|
-
//
|
|
1412
|
-
|
|
1413
|
-
|
|
3603
|
+
// Keep the name the user has changed: magpie stores a provider's name in
|
|
3604
|
+
// providers.json, and this hook runs on every start — forcing the default
|
|
3605
|
+
// back would silently undo a rename made in the UI, the same rule as the
|
|
3606
|
+
// account-nickname one ("a user-named thing is left alone"). A value that
|
|
3607
|
+
// is not an object counts as none.
|
|
3608
|
+
const was = config.provider[ID] && typeof config.provider[ID] === "object" ? config.provider[ID] : {}
|
|
3609
|
+
config.provider[ID] = {
|
|
1414
3610
|
...was,
|
|
1415
|
-
name: typeof was.name === "string" && was.name ? was.name :
|
|
3611
|
+
name: typeof was.name === "string" && was.name ? was.name : NAME,
|
|
1416
3612
|
}
|
|
1417
3613
|
},
|
|
1418
3614
|
}
|
|
1419
3615
|
}
|
|
1420
3616
|
|
|
1421
|
-
// Two entries: the domestic site (`msmoli`) and the international one
|
|
1422
|
-
// (`msmoli-intl`). Exporting two functions makes two providers — magpie's
|
|
1423
|
-
// loader takes every exported function as a plugin entry (`testables` is an
|
|
1424
|
-
// object, which it skips). The check-in window in msmoli.json is shared: it is
|
|
1425
|
-
// a personal preference about when to claim, not a per-site setting.
|
|
1426
|
-
export async function MsmoliPlugin(input) {
|
|
1427
|
-
return pluginFor(CN, input)
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
export async function MsmoliIntlPlugin(input) {
|
|
1431
|
-
return pluginFor(INTL, input)
|
|
1432
|
-
}
|
|
1433
3617
|
|
|
1434
3618
|
// testables is how the pure helpers the tests pin are reached without
|
|
1435
3619
|
// exporting them as functions: magpie's loader treats every exported function
|
|
1436
3620
|
// as a plugin entry (see windowOf above), but an object it skips. The tests
|
|
1437
3621
|
// are the only reader.
|
|
1438
|
-
export const testables = { windowOf, CN, INTL }
|
|
3622
|
+
export const testables = { windowOf, streakOf, CN, INTL, sites, maskAll, cardText, nameText, hostText, awaitsSignIn, gate }
|
|
1439
3623
|
|
|
1440
3624
|
export default MsmoliPlugin
|