magpie-plugin-msmoli 0.1.0 → 0.2.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 CHANGED
@@ -18,11 +18,55 @@ Daily grants are **24 hours only**. They do not accumulate: what you don't
18
18
  spend today is gone. A flagship model costs 2 魔粒 per call (mainstream 1,
19
19
  lightweight 0.5), so the daily 250 is roughly 125 calls.
20
20
 
21
+ ## Purpose, responsibility, and risk
22
+
23
+ **What it is for.** A personal utility: it claims the daily 魔粒 grant of the
24
+ ModelScope (魔搭) account *you* sign in, and does nothing else. It is not an
25
+ official ModelScope product; it is not affiliated with ModelScope, Alibaba or
26
+ magpie, and it collects nothing about you — the token stays in magpie's own
27
+ `plugin-auth.json`, and every request goes to the site the account signed in
28
+ to.
29
+
30
+ **The risk you are taking.** ModelScope has no check-in endpoint. The daily
31
+ grant exists for a *real* website sign-in, and this plugin obtains it by
32
+ calling `login/info` once a day with the browser's session cookie — a script
33
+ posing as the browser, not a button the official client offers. ModelScope's
34
+ user agreement forbids plugins that interfere with the service (4.1.2(1)) and
35
+ "刷分" with third-party tools (4.1.2(5)), and lets the platform freeze a
36
+ user's benefits or blacklist them for abuse (2.3.1 and the 魔粒 rules). An
37
+ account is real-name verified and often tied to Aliyun, so what is at stake is
38
+ the account, not the 250 credits that expire the same day. No account has been
39
+ reported frozen for running this, and no one can promise one never will be —
40
+ whether the service's detection would ever notice is not something this
41
+ project can answer. Whether to run it is a decision about your own account.
42
+
43
+ **Responsibility.** You are responsible for complying with ModelScope's terms
44
+ when you use this. The software is provided under the MIT licence, without
45
+ warranty of any kind; the maintainer is not liable for a suspended or frozen
46
+ account, lost credits, or anything else that follows from running it. See
47
+ [LICENSE](LICENSE).
48
+
49
+ **Not in the magpie market, and it will not be submitted.** magpie's community
50
+ market declined to list this package for exactly the reason above
51
+ ([plugins#714](https://github.com/magpie-community/plugins/pull/714)), and the
52
+ project has decided not to submit it again. It stays installable by name from
53
+ npm.
54
+
21
55
  ## Install
22
56
 
23
57
  Needs magpie itself (<https://usemagpie.ai>). On macOS, use `magpie --version` to check it's on PATH; if not, use the app's full path:
24
58
  `/Applications/magpie.app/Contents/MacOS/magpie`.
25
59
 
60
+ ### Install from npm
61
+
62
+ The package is on npm as `magpie-plugin-msmoli` (latest 0.1.0). Installs into
63
+ magpie's own plugin folder, and `magpie plugin update` follows the versions.
64
+
65
+ ```bash
66
+ magpie plugin add magpie-plugin-msmoli
67
+ printf 'ms-your-token\n' | magpie plugin login msmoli
68
+ ```
69
+
26
70
  ### Install via GitHub URL (no clone, auto-updates)
27
71
 
28
72
  This installs into magpie's own plugin folder (`~/.config/magpie/plugins` for a regular install, or `data/plugins` for the portable version), so it updates with `magpie plugin update`.
@@ -52,12 +96,34 @@ The sign-in is kept per provider id (`msmoli`) rather than per folder, so
52
96
  switching between the two forms (or moving the clone) keeps the token — no
53
97
  second login.
54
98
 
99
+ ### International site (modelscope.ai)
100
+
101
+ The plugin now provides a second provider for the international site:
102
+ `msmoli-intl` (ModelScope 魔粒(国际版)). The same `ms-` token format, the same
103
+ daily grant rules, **but tokens are site-specific** — a token from `modelscope.cn`
104
+ answers 401 on `modelscope.ai`, and vice versa.
105
+
106
+ The sign-in and check-in work the same way:
107
+
108
+ ```bash
109
+ printf 'ms-your-intl-token\n' | magpie plugin login msmoli-intl
110
+ ```
111
+
112
+ The check-in window (`msmoli.json`) is shared between both providers — it is
113
+ a personal preference, not a per-site setting. To turn on the international
114
+ check-in, set `msmoli-intl` in your plugin-checkins instead of or alongside
115
+ `msmoli` (see "Turn the check-in on" below, substituting the provider id).
116
+
55
117
  ### Already have ModelScope set up in magpie? Sync that key to the plugin
56
118
 
57
119
  If you added ModelScope earlier just for chat, its key is already in
58
120
  `providers.json`. This package ships a small CLI that logs the same token into
59
121
  the plugin, so you don't have to find it again.
60
122
 
123
+ It syncs the domestic site only, because the built-in preset it reads is the
124
+ `modelscope.cn` one. The international site has no preset; sign `msmoli-intl`
125
+ in by hand (`magpie plugin login msmoli-intl`).
126
+
61
127
  Installed from GitHub, magpie keeps its own copy in its plugin folder:
62
128
 
63
129
  ```bash
@@ -73,9 +139,9 @@ installs keep the same layout under their `data` folder; if detection misses,
73
139
  where things are: for a portable install that is its `data` folder, not the
74
140
  folder the executable sits in. `--magpie` takes a path, absolute or relative —
75
141
  a bare `magpie` is refused, because that one is resolved through `PATH` and the
76
- first `magpie` on it would be handed the token. `npx magpie-plugin-msmoli` will
77
- work once the package is on npm — npm 12 blocks `npx github:…` by default, so
78
- don't start from that.
142
+ first `magpie` on it would be handed the token. `npx magpie-plugin-msmoli` runs
143
+ the CLI from npm (published as 0.1.0) — npm 12 blocks `npx github:…` by
144
+ default, so don't start from that.
79
145
 
80
146
  `--dir` says where to *read*; magpie's CLI has no such flag, so a write always
81
147
  lands in the binary's own folder. Given both and they disagree, the tool stops
@@ -137,7 +203,9 @@ magpie):
137
203
 
138
204
  The token comes from <https://modelscope.cn/my/myaccesstoken>. The same `ms-`
139
205
  token is both the inference `Authorization: Bearer` and the web session cookie,
140
- so nothing else needs configuring.
206
+ so nothing else needs configuring. For the international provider
207
+ (`msmoli-intl`), the token comes from <https://modelscope.ai/my/myaccesstoken>
208
+ instead — the two sites' tokens do not interchange.
141
209
 
142
210
  From then on magpie claims it once a Beijing day per signed-in account, retries
143
211
  later that day if it fails, and reports the outcome on the account's usage card:
@@ -361,13 +429,19 @@ The one real overlap to know about: **both rows cost nothing extra**, but if you
361
429
  remove the plugin's sign-in, chat is unaffected, and if you remove the preset,
362
430
  the check-in keeps working. They fail independently.
363
431
 
432
+ If you sign in the international site as well, that adds one more row,
433
+ `msmoli-intl` (ModelScope 魔粒(国际版)). It has every property in the table
434
+ above — no models, its own card, its own check-in — and fails independently of
435
+ both the preset and the domestic plugin row.
436
+
364
437
  ## Privacy
365
438
 
366
439
  `login/info` responds with `Email` and `GitlabAccessToken` among other fields.
367
440
  This plugin reads `NickName` / `Name` and nothing else; the rest is dropped as
368
441
  soon as the response is parsed. The token lives only in magpie's own
369
442
  `plugin-auth.json` — the plugin never writes it anywhere, never logs it, never
370
- sends it anywhere but `modelscope.cn`.
443
+ sends it anywhere but the site the account was signed in for (`modelscope.cn`
444
+ for `msmoli`, `modelscope.ai` for `msmoli-intl`).
371
445
 
372
446
  The one thing it writes is that nickname, as `accountId` on the sign-in it
373
447
  already has, so magpie's card can name the account. It goes through the host's
@@ -381,6 +455,9 @@ nothing — your name stays, and the nickname is not put over it.
381
455
 
382
456
  ## Scope and limits
383
457
 
458
+ **ModelScope's terms and the risks of running this are stated above, in
459
+ [Purpose, responsibility, and risk](#purpose-responsibility-and-risk).**
460
+
384
461
  - One account's own daily grant, idempotent: a check-in reads the ledger, touches
385
462
  the sign-in endpoint, and reads the ledger again — three requests. A day already
386
463
  claimed (a retry, a later ask) answers `done` in two; a touch the ledger has not
@@ -419,7 +496,7 @@ uses, so a ledger title or a server message of ordinary words is untouched.
419
496
  |---|---|
420
497
  | `not signed in` | `printf 'ms-token\n' \| magpie plugin login msmoli` |
421
498
  | No 魔粒 window on the card, or no check-in | restart magpie (the plugin host caches) |
422
- | `failed` with HTTP 401 | the token was revoked — make a new one and sign in again |
499
+ | `failed` with HTTP 401 | the token was revoked, or it belongs to the other site — `modelscope.cn` and `modelscope.ai` do not share tokens. The card names which site refused (国内站 / 国际站). Make a new token on that site and sign in again |
423
500
  | `failed` with HTTP 403 | ModelScope's risk control refused the request; the token is fine. It retries in 30 minutes |
424
501
  | Before the window the card reads failed, with a line naming the claim time: `09:56 后自动领取(magpie 每半小时问一次,无需操作)` | expected — the card leads with when it will claim; see "When it claims" above |
425
502
  | Always `inactive` | the sign-in works but nothing shows in today's ledger: the account may not be eligible, or the grant hasn't been written yet (a claim made elsewhere shows as `done`). Before Beijing noon magpie asks again every 30 minutes; from noon on the plugin asks once more itself, half an hour later ("半小时后再核对一次"), and only the second `inactive` stands for the day |
package/README.zh-CN.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  一个 [magpie](https://github.com/yetone/magpie) 插件,替你领取 ModelScope(魔搭)的每日**魔粒**——每天自动签到一次,代替人工打开网页——并在账号卡片上显示余额与今日消耗。
4
4
 
5
+ > **Before you install:** new material is written in English only, and this
6
+ > translation does not cover the English README's "Purpose, responsibility,
7
+ > and risk" section. Read
8
+ > [Purpose, responsibility, and risk](README.md#purpose-responsibility-and-risk)
9
+ > in the English README first — it states what the plugin is for, the risk to
10
+ > your ModelScope account, and that the project is not listed in magpie's
11
+ > market and will not be submitted to it.
12
+
5
13
  ## 魔粒是什么
6
14
 
7
15
  ModelScope 的 API-Inference 是免费的,用魔粒结算:
@@ -19,7 +27,17 @@ ModelScope 的 API-Inference 是免费的,用魔粒结算:
19
27
  需要先装好 magpie(<https://usemagpie.ai>)。macOS 上用 `magpie --version` 确认命令在 `PATH` 上;
20
28
  不在的话用 app 里的完整路径 `/Applications/magpie.app/Contents/MacOS/magpie`。
21
29
 
22
- ### 方式一:直接从 GitHub 装(不用 clone,能跟着更新)
30
+ ### 方式一:从 npm 装(推荐,能跟着版本更新)
31
+
32
+ 包已发布在 npm:`magpie-plugin-msmoli`(最新 0.1.0)。装进 magpie 自己的插件目录,
33
+ `magpie plugin update` 跟着版本更新:
34
+
35
+ ```bash
36
+ magpie plugin add magpie-plugin-msmoli
37
+ printf 'ms-你的令牌\n' | magpie plugin login msmoli
38
+ ```
39
+
40
+ ### 方式二:直接从 GitHub 装(不用 clone,能跟着更新)
23
41
 
24
42
  装进 magpie 自己的插件目录(安装版是 `~/.config/magpie/plugins`,便携版是 data 目录下的 `plugins`),
25
43
  以后 `magpie plugin update` 就能更新:
@@ -29,7 +47,7 @@ magpie plugin add github:MisonL/magpie-plugin-msmoli
29
47
  printf 'ms-你的令牌\n' | magpie plugin login msmoli
30
48
  ```
31
49
 
32
- ### 方式二:clone 到本地再装
50
+ ### 方式三:clone 到本地再装
33
51
 
34
52
  留一份自己的副本,打算改代码就用这种。magpie 是**按位置引用**这个文件夹而不是拷走,
35
53
  所以文件夹不在(比如外置盘没挂)插件就加载不了,更新也得先 pull 再 add 一次:
@@ -61,8 +79,8 @@ node ~/.config/magpie/plugins/node_modules/magpie-plugin-msmoli/bin/sync.mjs --t
61
79
  便携版的目录结构相同,只是根目录是它的 `data` 文件夹;探测不到时用 `--dir <magpie 的配置
62
80
  目录>`(安装版是 `~/.config/magpie`,便携版是它的 `data` 目录)和 `--magpie <magpie 可执行
63
81
  文件的路径>` 指明。`--magpie` 要的是一个路径,绝对或相对都行;裸名 `magpie` 会被拒绝——
64
- 那个是按 `PATH` 解析的,PATH 上第一个 magpie 会拿到令牌。`npx magpie-plugin-msmoli` 要等
65
- 包发布到 npm 之后才能用——npm 12 默认禁用 `npx github:…`,别从那条路开始。
82
+ 那个是按 `PATH` 解析的,PATH 上第一个 magpie 会拿到令牌。`npx magpie-plugin-msmoli` 可以直接用
83
+ (已发布到 npm,0.1.0)——npm 12 默认禁用 `npx github:…`,别从那条路开始。
66
84
 
67
85
  `--dir` 只决定**读**哪里;magpie 的 CLI 没有这个参数,写入永远落在可执行文件自己的目录。
68
86
  两个都给了而指向**不同的地方**时,工具会在写之前停下,而不是读一个 magpie、写进另一个。
package/index.mjs CHANGED
@@ -1,21 +1,47 @@
1
- // ModelScope (魔搭) 魔粒 check-in for magpie.
1
+ // ModelScope (魔搭) 魔粒 check-in for magpie — one provider per site:
2
+ // modelscope.cn (`msmoli`) and modelscope.ai (`msmoli-intl`).
2
3
  //
3
4
  // magpie calls auth.checkin once a Beijing day per signed-in account while the
4
5
  // provider's check-in switch is on, and again later that day after a failure.
5
6
  // Hooks here: checkin, usage.
6
7
  //
7
- // 签到机制:ModelScope 的 OpenAPI 里没有签到端点。访问
8
- // GET /api/v1/users/login/info 会被服务端记成一次登录,随即发放当日
9
- // 「注册并登录 200」与「绑定阿里云账号 50」,当日幂等。
8
+ // How the claim works: ModelScope's OpenAPI has no check-in endpoint. What it
9
+ // has is a side effect — GET /api/v1/users/login/info counts as a website
10
+ // sign-in, and the server grants that day's credits with it (200 for the
11
+ // "注册并登录" daily grant, 50 for a bound Aliyun account), once per day.
12
+ // modelscope.ai is the same backend with the same paths and the same
13
+ // daily_active rule, but separate accounts and separate tokens: measured, an
14
+ // ms- token authenticates only on the site it came from.
10
15
  //
11
- // 本插件不提供任何聊天模型:推理仍走 providers.json 里 API key 版的
12
- // modelscope 预设。这个 provider 只为承载签到所需的登录态而存在。
16
+ // The plugin serves no chat models: inference keeps using the API-key modelscope
17
+ // preset in providers.json. Both providers exist only to carry the sign-in the
18
+ // check-in runs as.
13
19
 
14
20
  import { readFileSync } from "node:fs"
15
21
  import { homedir } from "node:os"
16
22
  import { isAbsolute, join } from "node:path"
17
23
 
18
- const SITE = "https://modelscope.cn"
24
+ // A profile is everything that differs between the two deployments: the
25
+ // origin, the provider id (magpie keys the sign-in, the card and the alias by
26
+ // it), and the words on the card. The rest of this module is shared — the two
27
+ // sites answer with the same envelopes, the same magicubes rules and the same
28
+ // 24-hour expiry, so a second copy of the hooks would only be a second place
29
+ // for them to drift.
30
+ const CN = {
31
+ site: "https://modelscope.cn",
32
+ id: "msmoli",
33
+ name: "ModelScope 魔粒",
34
+ plan: "魔搭社区",
35
+ where: "国内站",
36
+ }
37
+ const INTL = {
38
+ site: "https://modelscope.ai",
39
+ id: "msmoli-intl",
40
+ name: "ModelScope 魔粒(国际版)",
41
+ plan: "魔搭社区(国际版)",
42
+ where: "国际站",
43
+ }
44
+
19
45
  const LOGIN_INFO = "/api/v1/users/login/info"
20
46
  const BALANCE = "/openapi/v1/magicubes/balance"
21
47
  const TXNS = "/openapi/v1/magicubes/transactions"
@@ -26,8 +52,6 @@ const UA =
26
52
  // 每日发放的规则。这类魔粒 24 小时过期,不累积。
27
53
  const DAILY_RULES = ["daily_active", "aliyun_bindlogin"]
28
54
 
29
- const ID = "msmoli"
30
-
31
55
  const TIMEOUT_MS = 8000
32
56
  // PAGE_SIZE is one page of the ledger, and one page is all that is read. The
33
57
  // ledger is aggregated — one row per (day, type, rule_key, model_tier), with a
@@ -127,7 +151,7 @@ function saidFailure(e, ctl, budget) {
127
151
  return e
128
152
  }
129
153
 
130
- async function json(token, path, deadline = Infinity) {
154
+ async function json(site, token, path, deadline = Infinity) {
131
155
  const left = deadline - Date.now()
132
156
  if (left <= 0) throw new Error("预算已用完,本次请求未发出")
133
157
  const budget = Math.min(TIMEOUT_MS, left)
@@ -144,15 +168,15 @@ async function json(token, path, deadline = Infinity) {
144
168
  try {
145
169
  const res = await Promise.race([
146
170
  aborted,
147
- fetch(SITE + path, {
171
+ fetch(site + path, {
148
172
  // every redirect is refused, the site's own included: one that left
149
- // modelscope.cn would carry the session cookie off-site (the runtimes
173
+ // the origin would carry the session cookie off-site (the runtimes
150
174
  // happen not to forward it), and ModelScope's endpoints answer in place
151
175
  redirect: "error",
152
176
  headers: {
153
177
  Cookie: "m_session_id=" + token,
154
178
  "User-Agent": UA,
155
- Referer: SITE + "/",
179
+ Referer: site + "/",
156
180
  Accept: "application/json",
157
181
  },
158
182
  signal: ctl.signal,
@@ -502,7 +526,10 @@ function labelOf(auth, token) {
502
526
  // the user's name keyed to a label nothing carries any more — it would stop
503
527
  // showing, with no sign of why. A file that is mid-write, unreadable, or not
504
528
  // the shape we know must therefore count as "don't write", not as "no alias".
505
- function aliasOf(dir, label) {
529
+ //
530
+ // `id` is the provider's own: both sites keep their accounts in the same
531
+ // providers.json, and an alias is only this plugin's when the row's id says so.
532
+ function aliasOf(id, dir, label) {
506
533
  // no directory is not "the user has no alias": it is having nowhere to look
507
534
  if (!dir || !label) return undefined
508
535
  let raw
@@ -531,7 +558,7 @@ function aliasOf(dir, label) {
531
558
  for (const list of lists) {
532
559
  if (!Array.isArray(list)) continue
533
560
  for (const p of list) {
534
- if (p?.id !== ID) continue
561
+ if (p?.id !== id) continue
535
562
  const names = p.accountNames
536
563
  if (!names || typeof names !== "object") continue
537
564
  const mine = names[key]
@@ -832,7 +859,7 @@ const unusableName = (name, token) =>
832
859
  nameIsKey(name, token) ||
833
860
  ["__proto__", "constructor", "prototype"].includes(name)
834
861
 
835
- async function rememberAccountName(client, token, name, auth, dir) {
862
+ async function rememberAccountName(profile, client, token, name, auth, dir) {
836
863
  // "can write" is a function, not a truthy value: `{ auth: { set: 1 } }` is a
837
864
  // client that cannot write, and `!client?.auth?.set` let it through to call
838
865
  // a number — the TypeError is caught below, but it logs "account name not
@@ -846,7 +873,7 @@ async function rememberAccountName(client, token, name, auth, dir) {
846
873
  // it would re-key their alias to a label the account no longer has. A
847
874
  // providers.json that can't be read says the same thing — don't write over
848
875
  // a state we can't see.
849
- if (aliasOf(dir, labelOf(auth, token)) !== "") return
876
+ if (aliasOf(profile.id, dir, labelOf(auth, token)) !== "") return
850
877
  named.delete(token)
851
878
  named.set(token, Date.now())
852
879
  while (named.size > NAMES_MAX) named.delete(named.keys().next().value)
@@ -854,7 +881,7 @@ async function rememberAccountName(client, token, name, auth, dir) {
854
881
  // auth.set replaces the whole sign-in, so what the host handed us is the
855
882
  // base: a field we do not know about is kept, not dropped
856
883
  const body = { ...(auth && typeof auth === "object" ? auth : {}), type: "api", key: token, accountId: name }
857
- await client.auth.set({ path: { id: ID }, body })
884
+ await client.auth.set({ path: { id: profile.id }, body })
858
885
  } catch (e) {
859
886
  // the card keeps the key's tail; nothing else depends on this. Put the
860
887
  // token back so the next ask tries the write again, and let the log call
@@ -865,7 +892,7 @@ async function rememberAccountName(client, token, name, auth, dir) {
865
892
  // of it before it reaches magpie's log
866
893
  const why = cardText(whyOf(e), token)
867
894
  try {
868
- const r = client?.app?.log?.({ body: { level: "warn", service: "msmoli", message: `account name not written back: ${why}` } })
895
+ const r = client?.app?.log?.({ body: { level: "warn", service: profile.id, message: `account name not written back: ${why}` } })
869
896
  if (r && typeof r.catch === "function") r.catch(() => {})
870
897
  } catch {
871
898
  // a host whose log throws synchronously must not turn a successful
@@ -888,13 +915,15 @@ const whyOf = (e) =>
888
915
  // used to exist for one condition ("令牌失效或不属于国内站", the same plus
889
916
  // "(HTTP 401)", and a bare "HTTP 401" from the balance/ledger path), and the
890
917
  // README's troubleshooting row promises the user will see the status code in
891
- // it — which only two of the three carried.
892
- const EXPIRED = "令牌失效或不属于国内站(HTTP 401)"
918
+ // it — which only two of the three carried. `where` is the profile's 国内站/
919
+ // 国际站, and stays in the one wording so the card says which site refused
920
+ // the token.
921
+ const expired = (where) => `令牌失效或不属于${where}(HTTP 401)`
893
922
 
894
923
  // deadToken is what every 401 becomes: the token is revoked, and magpie only
895
924
  // signs the account out when the hook says so. Both call sites guard
896
925
  // `status === 401` before throwing this, so there is no other status to name.
897
- const deadToken = () => Object.assign(new Error(EXPIRED), { signIn: "expired" })
926
+ const deadToken = (where) => Object.assign(new Error(expired(where)), { signIn: "expired" })
898
927
 
899
928
  // RISK is what every 403 says — ModelScope's risk control, not a bad token:
900
929
  // the account may be perfectly fine and only the request was refused. The
@@ -907,15 +936,16 @@ const RISK = "被魔搭风控拒绝(HTTP 403)"
907
936
  // usage's ledger) do not each carry their own copy of the four checks. `what`
908
937
  // names the read in the message ("账单查询失败" / "余额查询失败" / "登录接口
909
938
  // 失败"); `risk` is the 403 wording, which the touch alone extends with the
910
- // retry it can promise.
939
+ // retry it can promise. `profile` is there for the one site-specific word a
940
+ // 401 carries (国内站/国际站).
911
941
  //
912
942
  // The parse check and the status check are separate on purpose: a body that
913
943
  // does not parse is not JSON; a body that parses and carries a 429/500 is the
914
944
  // server saying why in its own words (「请求过于频繁」「系统繁忙」). Merged,
915
945
  // a JSON-carrying 429 was reported as "响应不是 JSON" and sent the user to
916
946
  // check their proxy.
917
- const gate = (res, body, parsed, token, what, risk = RISK) => {
918
- if (res.status === 401) throw deadToken()
947
+ const gate = (profile, res, body, parsed, token, what, risk = RISK) => {
948
+ if (res.status === 401) throw deadToken(profile.where)
919
949
  if (res.status === 403) throw new Error(risk)
920
950
  if (!parsed) throw new Error(`${what}:响应不是 JSON(HTTP ${res.status})`)
921
951
  if (res.status !== 200 || !okOf(body)) throw new Error(`${what}:${saidOf(body, token) || `HTTP ${res.status}`}`)
@@ -925,9 +955,9 @@ const gate = (res, body, parsed, token, what, risk = RISK) => {
925
955
  // The day is read at each call, on purpose: a check-in that crosses midnight
926
956
  // between its reads reports what its touch granted on the new day, which is
927
957
  // true, where diffing both reads against the old day would answer inactive.
928
- async function todayEarn(token, deadline) {
929
- const { res, body, parsed } = await json(token, `${TXNS}?page=1&page_size=${PAGE_SIZE}`, deadline)
930
- gate(res, body, parsed, token, "账单查询失败")
958
+ async function todayEarn(profile, token, deadline) {
959
+ const { res, body, parsed } = await json(profile.site, token, `${TXNS}?page=1&page_size=${PAGE_SIZE}`, deadline)
960
+ gate(profile, res, body, parsed, token, "账单查询失败")
931
961
  const day = todayCN()
932
962
  return recordsOf(body).filter((r) => r?.type === "EARN" && onDay(r?.gmt_created, day))
933
963
  }
@@ -996,7 +1026,11 @@ const yin = (n) => {
996
1026
 
997
1027
  const sumOf = (records) => yin(records.reduce((n, r) => n + amountOf(r), 0))
998
1028
 
999
- export async function MsmoliPlugin(input) {
1029
+ // pluginFor is the whole plugin, with the one profile it serves passed in.
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) {
1000
1034
  // input.directory is magpie's own folder (host.go passes settings.Dir()),
1001
1035
  // which is where a portable magpie keeps everything too.
1002
1036
  const configDir = typeof input?.directory === "string" ? input.directory : ""
@@ -1007,7 +1041,7 @@ export async function MsmoliPlugin(input) {
1007
1041
 
1008
1042
  return {
1009
1043
  auth: {
1010
- provider: ID,
1044
+ provider: profile.id,
1011
1045
 
1012
1046
  // 静态令牌签到:粘贴一把 ms- 开头的访问令牌即可。
1013
1047
  //
@@ -1086,7 +1120,7 @@ export async function MsmoliPlugin(input) {
1086
1120
  let name = ""
1087
1121
  const answer = await (async () => {
1088
1122
  try {
1089
- const before = await todayEarn(token, deadline)
1123
+ const before = await todayEarn(profile, token, deadline)
1090
1124
  const beforeDaily = before.filter((r) => DAILY_RULES.includes(r?.rule_key))
1091
1125
  const claimed = before.some((r) => r?.rule_key === "daily_active")
1092
1126
  // What this run granted is the ledger's growth, not a new key: the
@@ -1129,9 +1163,9 @@ export async function MsmoliPlugin(input) {
1129
1163
  if (claimed && known) {
1130
1164
  name = known
1131
1165
  } else {
1132
- const { res, body, parsed } = await json(token, LOGIN_INFO, deadline)
1166
+ const { res, body, parsed } = await json(profile.site, token, LOGIN_INFO, deadline)
1133
1167
  // 403 是风控,不是令牌坏了:账号可能是好的,只是这次请求被拒
1134
- gate(res, body, parsed, token, "登录接口失败", `${RISK},稍后会自动重试`)
1168
+ gate(profile, res, body, parsed, token, "登录接口失败", `${RISK},稍后会自动重试`)
1135
1169
 
1136
1170
  const me = dataOf(body)
1137
1171
  // A name is a string. Anything else — a number, an object, a list —
@@ -1178,7 +1212,7 @@ export async function MsmoliPlugin(input) {
1178
1212
  return { credit, which }
1179
1213
  }
1180
1214
 
1181
- const daily = gained((await todayEarn(token, deadline)).filter((r) => DAILY_RULES.includes(r?.rule_key)))
1215
+ const daily = gained((await todayEarn(profile, token, deadline)).filter((r) => DAILY_RULES.includes(r?.rule_key)))
1182
1216
  if (daily) return { outcome: "claimed", credit: daily.credit, message: `领取 ${daily.credit} 魔粒:${daily.which}` }
1183
1217
  // 触达了却没有到账记录:可能是账本还没写出来(发放是异步的),
1184
1218
  // 也可能这个账号本来就不在活动范围。再读一次账本确认,别把刚写
@@ -1187,7 +1221,7 @@ export async function MsmoliPlugin(input) {
1187
1221
  // 是宿主:还没见到领取的 done/inactive 是暂定答案,每 30 分钟重问
1188
1222
  // (provisional,由 checkinSureHour 决定)。12 点起宿主就当天了结,
1189
1223
  // 所以下午的时段由插件自己用 until 要一次半小时后的重问。
1190
- const later = gained((await todayEarn(token, deadline)).filter((r) => DAILY_RULES.includes(r?.rule_key)))
1224
+ const later = gained((await todayEarn(profile, token, deadline)).filter((r) => DAILY_RULES.includes(r?.rule_key)))
1191
1225
  if (later) return { outcome: "claimed", credit: later.credit, message: `领取 ${later.credit} 魔粒:${later.which}` }
1192
1226
  const hourCN = Math.floor(minuteCN() / 60)
1193
1227
  if (hourCN >= SURE_HOUR && deferred.get(token) !== today) {
@@ -1208,7 +1242,7 @@ export async function MsmoliPlugin(input) {
1208
1242
  // catches its own failures, and anything it could still throw (an
1209
1243
  // unreadable providers.json read) must not undo the answer above
1210
1244
  try {
1211
- await rememberAccountName(client, token, name, auth, configDir)
1245
+ await rememberAccountName(profile, client, token, name, auth, configDir)
1212
1246
  } catch {}
1213
1247
  return answer
1214
1248
  },
@@ -1232,9 +1266,9 @@ export async function MsmoliPlugin(input) {
1232
1266
  // Inside the try: the hook promises an {error}, never a rejection,
1233
1267
  // and this path reaches client.auth.set.
1234
1268
  try {
1235
- await rememberAccountName(client, token, names.get(token) ?? "", auth, configDir)
1236
- const bal = await json(token, BALANCE, deadline)
1237
- gate(bal.res, bal.body, bal.parsed, token, "余额查询失败")
1269
+ await rememberAccountName(profile, client, token, names.get(token) ?? "", auth, configDir)
1270
+ const bal = await json(profile.site, token, BALANCE, deadline)
1271
+ gate(profile, bal.res, bal.body, bal.parsed, token, "余额查询失败")
1238
1272
  // data 缺失读成 0 会把「没读懂」翻译成「余额为零」——一个确定又
1239
1273
  // 错误的结论,卡片会写「0 魔粒可用」。账本那侧的 recordsOf 同理。
1240
1274
  // 字段这一层也一样:available_balance 缺失、null、或改了名字,
@@ -1259,8 +1293,8 @@ export async function MsmoliPlugin(input) {
1259
1293
  // below zero, and "-5 魔粒可用" is not a thing the card can show
1260
1294
  const available = Math.max(0, yin(availableN))
1261
1295
 
1262
- const tx = await json(token, `${TXNS}?page=1&page_size=${PAGE_SIZE}`, deadline)
1263
- gate(tx.res, tx.body, tx.parsed, token, "账单查询失败")
1296
+ const tx = await json(profile.site, token, `${TXNS}?page=1&page_size=${PAGE_SIZE}`, deadline)
1297
+ gate(profile, tx.res, tx.body, tx.parsed, token, "账单查询失败")
1264
1298
 
1265
1299
  const day = todayCN()
1266
1300
  let earned = 0
@@ -1340,7 +1374,7 @@ export async function MsmoliPlugin(input) {
1340
1374
  const hint = ahead && !ahead.due ? ` · ${clock(ahead.target)} 领取` : ""
1341
1375
 
1342
1376
  return {
1343
- plan: "魔搭社区",
1377
+ plan: profile.plan,
1344
1378
  user: names.get(token) ?? "",
1345
1379
  balance: `${available} 魔粒可用`,
1346
1380
  windows: [
@@ -1367,27 +1401,40 @@ export async function MsmoliPlugin(input) {
1367
1401
  async config(config) {
1368
1402
  // 宿主是 `{ provider: {}, ...userConfig }` 拼出来的,用户配置里那个
1369
1403
  // provider 会盖掉默认的 {}。它不是对象时(旧版本写的字符串、手改的
1370
- // 数字),下面 `[ID] =` 会抛 TypeError,宿主只把 config 钩子的错误
1371
- // 记进日志——插件照常出现,只是没有名字,看不出哪里错了。`??=` 只挡
1372
- // null/undefined,挡不住 ""、0、false;一个装不下 ID 的容器里没有可
1373
- // 保留的东西,整个换掉。
1404
+ // 数字),下面 `[profile.id] =` 会抛 TypeError,宿主只把 config 钩子
1405
+ // 的错误记进日志——插件照常出现,只是没有名字,看不出哪里错了。
1406
+ // `??=` 只挡 null/undefined,挡不住 ""、0、false;一个装不下 id 的
1407
+ // 容器里没有可保留的东西,整个换掉。
1374
1408
  if (!config.provider || typeof config.provider !== "object") config.provider = {}
1375
1409
  // 用户自己改过的名字留着:magpie 把 provider 的名字存在 providers.json
1376
1410
  // 里,而钩子每次启动都跑一遍——强制写回默认值会把 UI 里的改名悄悄撤销,
1377
1411
  // 与账号昵称那条「用户命名过就不动」是同一条规矩。值不是对象时当作没有。
1378
- const was = config.provider[ID] && typeof config.provider[ID] === "object" ? config.provider[ID] : {}
1379
- config.provider[ID] = {
1412
+ const was = config.provider[profile.id] && typeof config.provider[profile.id] === "object" ? config.provider[profile.id] : {}
1413
+ config.provider[profile.id] = {
1380
1414
  ...was,
1381
- name: typeof was.name === "string" && was.name ? was.name : "ModelScope 魔粒",
1415
+ name: typeof was.name === "string" && was.name ? was.name : profile.name,
1382
1416
  }
1383
1417
  },
1384
1418
  }
1385
1419
  }
1386
1420
 
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
+
1387
1434
  // testables is how the pure helpers the tests pin are reached without
1388
1435
  // exporting them as functions: magpie's loader treats every exported function
1389
1436
  // as a plugin entry (see windowOf above), but an object it skips. The tests
1390
1437
  // are the only reader.
1391
- export const testables = { windowOf }
1438
+ export const testables = { windowOf, CN, INTL }
1392
1439
 
1393
1440
  export default MsmoliPlugin
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "magpie-plugin-msmoli",
3
- "version": "0.1.0",
4
- "description": "ModelScope (魔搭) 魔粒 daily check-in and balance for magpie — one check-in a day instead of opening the site",
3
+ "version": "0.2.0",
4
+ "description": "ModelScope (魔搭) 魔粒 daily check-in and balance for magpie, for modelscope.cn and modelscope.ai — one check-in a day instead of opening the site",
5
5
  "author": "MisonL",
6
6
  "type": "module",
7
7
  "repository": {