fix: resolve step 9 profile fallback with latest dev

This commit is contained in:
QLHazyCoder
2026-05-06 01:51:04 +08:00
132 changed files with 28350 additions and 4307 deletions
+3
View File
@@ -10,3 +10,6 @@
.omx/
/node_modules
/.runtime
/docs/新步骤顺序
# Local exported runtime settings
/config.json
+18 -197
View File
@@ -1,201 +1,22 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
MIT License
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Copyright (c) 2026 whwh1233
Copyright (c) 2026 QLHazyCoder and contributors
1. Definitions.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11 -3
View File
@@ -48,8 +48,8 @@
- 页面要求填写 `age`
- 支持 `Auto` 多轮运行
- 支持中途 `Stop`
- 支持通过日志区的 `记录` 按钮查看邮箱记录面板,按邮箱展示最终状态、时间、失败标签和重试次数
- 支持将邮箱记录完整快照同步到本地 helper,便于开发者直接查看 `data/account-run-history.json`
- 支持通过日志区的 `记录` 按钮查看账号记录面板,同一轮的邮箱和手机号会合并显示,按最终状态、时间、失败标签和重试次数筛选
- 支持将账号记录完整快照同步到本地 helper,便于开发者直接查看 `data/account-run-history.json`
- Step 8 会自动寻找 OAuth 同意页的“继续”按钮,并通过 Chrome debugger 输入事件发起点击,然后监听本地回调地址
@@ -308,7 +308,7 @@ python3 scripts/hotmail_helper.py
Hotmail helper listening on http://127.0.0.1:17373
```
同时还会输出本地邮箱记录快照文件路径。看到这些输出后,再回到扩展里点 `校验``复制最新验证码`邮箱记录快照会按默认本地 helper 地址自动同步,无需再手动开启本地同步。
同时还会输出本地账号记录快照文件路径。看到这些输出后,再回到扩展里点 `校验``复制最新验证码`账号记录快照会按默认本地 helper 地址自动同步,无需再手动开启本地同步。
#### 最小排错说明
@@ -835,3 +835,11 @@ sidepanel/ 侧边栏 UI
- 没有硬编码你的 CPA 地址、密码或账户
- 自定义密码只存在当前会话存储中
- 邮箱和密码会被记录到本轮 `accounts` 中,便于追踪本次运行结果
## 来源与致谢
本项目早期代码与 [whwh1233/StepFlow-Duck](https://github.com/whwh1233/StepFlow-Duck) 同源,共同历史截至 `387e177e005e9863f3de193ad8b954e9efb5fd1d`。当前维护者最初接触到的是社群内分享的 zip 软件包,当时压缩包内没有 `LICENSE` 文件,也没有任何 GitHub 远程仓库地址或开源协议声明。
后续核对发现,原作者也将同源早期代码发布到了 GitHub,并在 `72218aab151a2ff74bf1763684a6370657c7bc57` 提交补充 MIT License。当前仓库已改为 MIT License,并补充原作者署名与来源说明。完整时间线见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。
感谢原作者 whwh1233、Jimmy 以及后续所有贡献者的早期工作和持续改进。当前仓库中的相关服务入口、贡献入口、交流群入口和其他维护者整理的入口,不代表原项目作者背书。
+42
View File
@@ -0,0 +1,42 @@
# 项目来源与许可说明
说明一下项目来源时间线:
当前维护者最初接触到的版本来自社群内分享的 zip 软件包。当时压缩包内没有 `LICENSE` 文件,也没有任何 GitHub 远程仓库地址或开源协议声明。
后续当前维护者基于收到的这份版本继续维护,并创建了当前仓库。
今天(2026 年 5 月 5 日)有人在社群讨论当前仓库未标明来源,当前维护者从其他人口中才获取到原作者也将同源早期代码发布到了 GitHub:
https://github.com/whwh1233/StepFlow-Duck
两个仓库共同历史截至:
```text
387e177e005e9863f3de193ad8b954e9efb5fd1d
```
之后两边走了不同分支:
- 原作者仓库:继续到 `a027a3a``d65f279`,并在 `72218aa` 提交添加 MIT License。
- 当前维护版本:接收并合入 Jimmy 的后续提交,直到 `f79166b`,之后由当前维护者继续维护。
因此,这不是“从原作者 MIT GitHub 仓库拿代码后改协议”。当前维护者收到和维护的版本当时没有附带远程仓库地址,也没有协议文件。
但既然现在已经确认原作者 GitHub 仓库后续补充了 MIT License,当前仓库已补充来源说明、原作者署名和 MIT 许可声明,避免后续误解。
## 原项目
- 原项目:`whwh1233/StepFlow-Duck`
- 原项目地址:<https://github.com/whwh1233/StepFlow-Duck>
- 共同历史截至:`387e177e005e9863f3de193ad8b954e9efb5fd1d`
- 原项目添加 MIT License 的提交:`72218aab151a2ff74bf1763684a6370657c7bc57`
原项目版权声明:
```text
Copyright (c) 2026 whwh1233
Licensed under the MIT License.
```
当前仓库后续由 QLHazyCoder 及其他贡献者继续维护、修改和扩展。当前仓库中的相关服务入口、贡献入口、交流群入口和其他维护者整理的入口,不代表原项目作者背书。
+1212 -299
View File
File diff suppressed because it is too large Load Diff
+140 -12
View File
@@ -90,8 +90,99 @@
return '流程失败';
}
function buildRecordId(email = '') {
return String(email || '').trim().toLowerCase();
function normalizeAccountIdentifierType(value = '') {
return String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email';
}
function normalizeAccountIdentifierValue(value = '', identifierType = 'email') {
const normalizedValue = String(value || '').trim();
if (!normalizedValue) {
return '';
}
return normalizeAccountIdentifierType(identifierType) === 'phone'
? normalizedValue
: normalizedValue.toLowerCase();
}
function getActivationPhoneNumber(activation = null) {
if (!activation || typeof activation !== 'object' || Array.isArray(activation)) {
return '';
}
return String(
activation.phoneNumber
?? activation.number
?? activation.phone
?? ''
).trim();
}
function resolveStatePhoneNumber(state = {}) {
const identifierType = String(state?.accountIdentifierType || '').trim().toLowerCase();
const accountIdentifierPhone = identifierType === 'phone'
? String(state?.accountIdentifier || '').trim()
: '';
return String(
state?.phoneNumber
|| state?.signupPhoneNumber
|| accountIdentifierPhone
|| getActivationPhoneNumber(state?.signupPhoneCompletedActivation)
|| getActivationPhoneNumber(state?.signupPhoneActivation)
|| getActivationPhoneNumber(state?.currentPhoneActivation)
|| ''
).trim();
}
function normalizePhoneRecordKey(value = '') {
const rawValue = String(value || '').trim();
const digits = rawValue.replace(/\D+/g, '');
return digits || rawValue.toLowerCase();
}
function resolveRecordIdentity(record = {}) {
const rawEmail = String(record.email || '').trim().toLowerCase();
const rawPhoneNumber = String(record.phoneNumber ?? record.phone ?? record.number ?? '').trim();
const rawIdentifierType = String(record.accountIdentifierType || '').trim().toLowerCase();
const inferredIdentifierType = rawIdentifierType === 'phone'
? 'phone'
: (rawIdentifierType === 'email'
? 'email'
: ((!rawEmail && rawPhoneNumber) ? 'phone' : 'email'));
const rawAccountIdentifier = String(
record.accountIdentifier
|| (inferredIdentifierType === 'phone' ? rawPhoneNumber : rawEmail)
|| ''
).trim();
const accountIdentifierType = rawAccountIdentifier
? normalizeAccountIdentifierType(inferredIdentifierType)
: (rawEmail ? 'email' : (rawPhoneNumber ? 'phone' : ''));
const accountIdentifier = normalizeAccountIdentifierValue(
rawAccountIdentifier || (accountIdentifierType === 'phone' ? rawPhoneNumber : rawEmail),
accountIdentifierType || inferredIdentifierType
);
const email = rawEmail || (accountIdentifierType === 'email' ? accountIdentifier : '');
const phoneNumber = rawPhoneNumber || (accountIdentifierType === 'phone' ? accountIdentifier : '');
return {
email,
phoneNumber,
accountIdentifierType,
accountIdentifier,
};
}
function buildRecordId(identifier = '', identifierType = 'email') {
const normalizedIdentifierType = normalizeAccountIdentifierType(identifierType);
const normalizedIdentifier = normalizeAccountIdentifierValue(identifier, normalizedIdentifierType);
if (!normalizedIdentifier) {
return '';
}
if (normalizedIdentifierType === 'phone' && /^phone:/i.test(normalizedIdentifier)) {
return normalizedIdentifier.toLowerCase();
}
return normalizedIdentifierType === 'phone'
? `phone:${normalizedIdentifier.toLowerCase()}`
: normalizedIdentifier;
}
function normalizeSource(value = '') {
@@ -140,11 +231,15 @@
return null;
}
const email = String(record.email || '').trim();
const password = String(record.password || '').trim();
const identity = resolveRecordIdentity(record);
const email = identity.email;
const phoneNumber = identity.phoneNumber;
const accountIdentifierType = identity.accountIdentifierType;
const accountIdentifier = identity.accountIdentifier;
const password = String(record.password ?? '').trim();
const finalStatus = normalizeFinalStatus(record.finalStatus || record.status || '');
if (!email || !password || !finalStatus) {
if (!accountIdentifier || !finalStatus) {
return null;
}
@@ -167,8 +262,11 @@
const rawFailureLabel = String(record.failureLabel || '').trim();
return {
recordId: String(record.recordId || '').trim() || buildRecordId(email),
recordId: String(record.recordId || '').trim() || buildRecordId(accountIdentifier, accountIdentifierType),
accountIdentifierType,
accountIdentifier,
email,
phoneNumber,
password,
finalStatus,
finishedAt,
@@ -215,11 +313,20 @@
}
function buildAccountRunHistoryRecord(state = {}, status = '', reason = '') {
const email = String(state.email || '').trim();
const password = String(state.password || state.customPassword || '').trim() || '无';
const identity = resolveRecordIdentity({
accountIdentifierType: state.accountIdentifierType,
accountIdentifier: state.accountIdentifier,
email: state.email,
phoneNumber: resolveStatePhoneNumber(state),
});
const email = identity.email;
const phoneNumber = identity.phoneNumber;
const accountIdentifierType = identity.accountIdentifierType;
const accountIdentifier = identity.accountIdentifier;
const password = String(state.password || state.customPassword || '').trim();
const finalStatus = normalizeFinalStatus(status);
if (!email || !finalStatus) {
if (!accountIdentifier || !finalStatus) {
return null;
}
@@ -233,8 +340,11 @@
const finishedAt = new Date().toISOString();
return {
recordId: buildRecordId(email),
recordId: buildRecordId(accountIdentifier, accountIdentifierType),
accountIdentifierType,
accountIdentifier,
email,
phoneNumber,
password,
finalStatus,
finishedAt,
@@ -257,10 +367,23 @@
const recordId = String(record.recordId || '').trim();
const emailKey = String(record.email || '').trim().toLowerCase();
const phoneKey = normalizePhoneRecordKey(record.phoneNumber);
const identifierKey = buildRecordId(
record.accountIdentifier || record.email || record.phoneNumber,
record.accountIdentifierType || (phoneKey && !emailKey ? 'phone' : 'email')
);
const nextHistory = normalizedHistory.filter((item) => {
const itemRecordId = String(item.recordId || '').trim();
const itemEmailKey = String(item.email || '').trim().toLowerCase();
return itemRecordId !== recordId && itemEmailKey !== emailKey;
const itemPhoneKey = normalizePhoneRecordKey(item.phoneNumber);
const itemIdentifierKey = buildRecordId(
item.accountIdentifier || item.email || item.phoneNumber,
item.accountIdentifierType || (itemPhoneKey && !itemEmailKey ? 'phone' : 'email')
);
return itemRecordId !== recordId
&& itemIdentifierKey !== identifierKey
&& (!emailKey || itemEmailKey !== emailKey)
&& (!phoneKey || itemPhoneKey !== phoneKey);
});
nextHistory.unshift(record);
@@ -283,7 +406,12 @@
}
const selectedIds = new Set(normalizedIds);
const nextHistory = normalizedHistory.filter((record) => !selectedIds.has(buildRecordId(record.recordId || record.email)));
const nextHistory = normalizedHistory.filter((record) => !selectedIds.has(buildRecordId(
record.recordId || record.accountIdentifier || record.email || record.phoneNumber,
String(record.recordId || '').startsWith('phone:') || String(record.accountIdentifierType || '').trim().toLowerCase() === 'phone'
? 'phone'
: 'email'
)));
return {
deletedCount: normalizedHistory.length - nextHistory.length,
+106 -9
View File
@@ -23,7 +23,10 @@
getState,
hasSavedProgress,
isAddPhoneAuthFailure,
isPhoneSmsPlatformRateLimitFailure,
isPlusCheckoutNonFreeTrialFailure,
isRestartCurrentAttemptError,
isStep4Route405RecoveryLimitFailure,
isSignupUserAlreadyExistsFailure,
isStopError,
launchAutoRunTimerPlan,
@@ -123,6 +126,18 @@
&& state.customMailProviderPool.length > 0;
}
function isPhoneNumberSupplyExhaustedFailure(error) {
const text = String(
typeof getErrorMessage === 'function'
? getErrorMessage(error)
: (error?.message || error || '')
).trim();
if (!text) {
return false;
}
return /no\s+numbers\s+available\s+across|all provider candidates failed to acquire number|no\s+free\s+phones|numbers?\s+not\s+found|no\s+numbers\s+within\s+maxprice|countries\s+are\s+empty|均无可用号码|暂无可用号码|无可用号码|接码号池暂无|\bNO_NUMBERS\b/i.test(text);
}
async function logAutoRunFinalSummary(totalRuns, roundSummaries = []) {
const summaries = buildAutoRunRoundSummaries(totalRuns, roundSummaries);
const successRounds = summaries.filter((item) => item.status === 'success');
@@ -402,6 +417,7 @@
autoRunDelayEnabled: prevState.autoRunDelayEnabled,
autoRunDelayMinutes: prevState.autoRunDelayMinutes,
autoStepDelaySeconds: prevState.autoStepDelaySeconds,
signupMethod: prevState.signupMethod,
mailProvider: prevState.mailProvider,
emailGenerator: prevState.emailGenerator,
gmailBaseEmail: prevState.gmailBaseEmail,
@@ -501,13 +517,24 @@
const reason = getErrorMessage(err);
roundSummary.failureReasons.push(reason);
const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err);
const blockedByPhoneSupplyExhausted = isPhoneNumberSupplyExhaustedFailure(err);
const blockedByPhoneSmsRateLimit = typeof isPhoneSmsPlatformRateLimitFailure === 'function'
&& isPhoneSmsPlatformRateLimitFailure(err);
const blockedByPhoneNoSupply = !blockedByPhoneSmsRateLimit
&& isPhoneNumberSupplyExhaustedFailure(err);
const blockedByAddPhone = !blockedByPhoneSmsRateLimit
&& !blockedByPhoneNoSupply
&& typeof isAddPhoneAuthFailure === 'function'
&& isAddPhoneAuthFailure(err);
const blockedByPlusNonFreeTrial = typeof isPlusCheckoutNonFreeTrialFailure === 'function'
&& isPlusCheckoutNonFreeTrialFailure(err);
const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function'
&& !keepSameEmailUntilAddPhone
&& isSignupUserAlreadyExistsFailure(err);
const blockedByStep4Route405 = typeof isStep4Route405RecoveryLimitFailure === 'function'
&& isStep4Route405RecoveryLimitFailure(err);
const canRetry = !blockedByAddPhone
&& !blockedByPhoneSupplyExhausted
&& !blockedByPhoneNoSupply
&& !blockedByPlusNonFreeTrial
&& !blockedBySignupUserAlreadyExists
&& autoRunSkipFailures
&& attemptRun < maxAttemptsForRound;
@@ -551,6 +578,76 @@
break;
}
if (blockedByPhoneNoSupply) {
roundSummary.status = 'failed';
roundSummary.finalFailureReason = reason;
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
});
await appendRoundRecordIfNeeded('failed', reason);
cancelPendingCommands('当前轮因接码号池暂无可用号码已终止。');
await broadcastStopToContentScripts();
if (!autoRunSkipFailures) {
await addLog(
`${targetRun}/${totalRuns} 轮接码号池暂无可用号码,自动重试未开启,当前自动运行将停止。`,
'warn'
);
stoppedEarly = true;
await broadcastAutoRunStatus('stopped', {
currentRun: targetRun,
totalRuns,
attemptRun,
sessionId: 0,
});
break;
}
await addLog(`${targetRun}/${totalRuns} 轮接码号池暂无可用号码,本轮将直接失败并跳过剩余重试。`, 'warn');
await addLog(
targetRun < totalRuns
? `${targetRun}/${totalRuns} 轮因接码号池暂无可用号码提前结束,自动流程将继续下一轮。`
: `${targetRun}/${totalRuns} 轮因接码号池暂无可用号码提前结束,已无后续轮次,本次自动运行结束。`,
'warn'
);
forceFreshTabsNextRun = true;
break;
}
if (blockedByPlusNonFreeTrial) {
roundSummary.status = 'failed';
roundSummary.finalFailureReason = reason;
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
});
await appendRoundRecordIfNeeded('failed', reason);
cancelPendingCommands('当前轮因 Plus 免费试用资格不可用已终止。');
await broadcastStopToContentScripts();
if (!autoRunSkipFailures) {
await addLog(
`${targetRun}/${totalRuns} 轮检测到 Plus 今日应付金额非 0,自动重试未开启,当前自动运行将停止。`,
'warn'
);
stoppedEarly = true;
await broadcastAutoRunStatus('stopped', {
currentRun: targetRun,
totalRuns,
attemptRun,
sessionId: 0,
});
break;
}
await addLog(`${targetRun}/${totalRuns} 轮没有 Plus 免费试用资格,本轮将直接失败并跳过剩余重试。`, 'warn');
await addLog(
targetRun < totalRuns
? `${targetRun}/${totalRuns} 轮因 Plus 今日应付金额非 0 提前结束,自动流程将继续下一轮。`
: `${targetRun}/${totalRuns} 轮因 Plus 今日应付金额非 0 提前结束,已无后续轮次,本次自动运行结束。`,
'warn'
);
forceFreshTabsNextRun = true;
break;
}
if (blockedBySignupUserAlreadyExists) {
roundSummary.status = 'failed';
roundSummary.finalFailureReason = reason;
@@ -586,18 +683,18 @@
break;
}
if (blockedByPhoneSupplyExhausted) {
if (blockedByStep4Route405) {
roundSummary.status = 'failed';
roundSummary.finalFailureReason = reason;
await setState({
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
});
await appendRoundRecordIfNeeded('failed', reason);
cancelPendingCommands('当前轮因接码号池不可用已终止。');
cancelPendingCommands('当前轮因步骤 4 连续 405 错误已终止。');
await broadcastStopToContentScripts();
if (!autoRunSkipFailures) {
await addLog(
`${targetRun}/${totalRuns}触发接码号池不可用,自动重试未开启,当前自动运行将停止。`,
`${targetRun}/${totalRuns}步骤 4 连续 405 恢复失败,自动重试未开启,当前自动运行将停止。`,
'warn'
);
stoppedEarly = true;
@@ -610,11 +707,11 @@
break;
}
await addLog(`${targetRun}/${totalRuns}接码号池暂不可用,本轮将直接失败并跳过剩余重试。`, 'warn');
await addLog(`${targetRun}/${totalRuns}步骤 4 连续 405 恢复失败,本轮将直接失败并跳过剩余重试。`, 'warn');
await addLog(
targetRun < totalRuns
? `${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,自动流程将继续下一轮。`
: `${targetRun}/${totalRuns} 轮因接码号池不可用提前结束,已无后续轮次,本次自动运行结束。`,
? `${targetRun}/${totalRuns} 轮因步骤 4 连续 405 提前结束,自动流程将继续下一轮。`
: `${targetRun}/${totalRuns} 轮因步骤 4 连续 405 提前结束,已无后续轮次,本次自动运行结束。`,
'warn'
);
forceFreshTabsNextRun = true;
+291 -5
View File
@@ -48,6 +48,11 @@ const IP_PROXY_EXIT_PROBE_ENDPOINTS_711_STICKY = [
// 与 curl 口径对齐,保持单端点,避免多站点探测引入额外波动与耗时。
'https://ipinfo.io/json',
];
const IP_PROXY_TARGET_REACHABILITY_ENDPOINTS = [
// Step 1 的真实目标站点;出口 IP 可用不代表该站点的 CONNECT/TLS 链路可用。
'https://chatgpt.com/',
];
const IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS = 8000;
const IP_PROXY_BACKGROUND_PROBE_MAX_ENDPOINTS = 4;
const IP_PROXY_BACKGROUND_PROBE_PER_ENDPOINT_TIMEOUT_MS = 3500;
const IP_PROXY_PAGE_CONTEXT_PROBE_URL = 'https://example.com/';
@@ -1270,6 +1275,7 @@ function buildIpProxyRoutingStatePatch(status = {}) {
const exitDetecting = Boolean(status?.exitDetecting);
const exitError = String(status?.exitError || '').trim();
const exitSource = String(status?.exitSource || '').trim().toLowerCase();
const exitEndpoint = String(status?.exitEndpoint || status?.endpoint || '').trim();
return {
ipProxyApplied: applied,
ipProxyAppliedReason: reason,
@@ -1286,6 +1292,7 @@ function buildIpProxyRoutingStatePatch(status = {}) {
ipProxyAppliedExitDetecting: exitDetecting,
ipProxyAppliedExitError: exitError,
ipProxyAppliedExitSource: exitSource,
ipProxyAppliedExitEndpoint: exitEndpoint,
};
}
@@ -1317,6 +1324,23 @@ async function setIpProxyLeakGuardEnabled(enabled) {
}).catch(() => { });
}
function shouldEnableIpProxyLeakGuardForStatus(status = {}) {
if (!status?.enabled) {
return false;
}
if (status?.applied) {
return false;
}
const reason = String(status?.reason || '').trim().toLowerCase();
// connectivity_failed 表示 PAC/代理接管已经下发,只是探测或目标站点失败。
// 此时继续启用 DNR 会把 chatgpt.com 变成 ERR_BLOCKED_BY_CLIENT,掩盖真实代理链路结果。
return reason !== 'connectivity_failed';
}
async function syncIpProxyLeakGuardForStatus(status = {}) {
await setIpProxyLeakGuardEnabled(shouldEnableIpProxyLeakGuardForStatus(status));
}
async function fetchWithTimeout(url, options = {}, timeoutMs = IP_PROXY_FETCH_TIMEOUT_MS) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || IP_PROXY_FETCH_TIMEOUT_MS));
@@ -1495,6 +1519,15 @@ function resolveExitProbeEndpoints(options = {}) {
return IP_PROXY_EXIT_PROBE_ENDPOINTS.slice();
}
function resolveTargetReachabilityEndpoints(options = {}) {
const configured = Array.isArray(options?.targetReachabilityEndpoints)
? options.targetReachabilityEndpoints.map((item) => String(item || '').trim()).filter(Boolean)
: [];
return configured.length
? configured
: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS.slice();
}
function applyExitRegionExpectation(status = {}, expectedRegion = '') {
const exitIp = String(status?.exitIp || '').trim();
const exitError = String(status?.exitError || '').trim();
@@ -1656,6 +1689,48 @@ function applyExitBaselineExpectation(status = {}) {
};
}
function shouldVerifyIpProxyTargetReachability(status = {}) {
if (!String(status?.exitIp || '').trim()) {
return false;
}
if (status?.applied === false && String(status?.reason || '').trim().toLowerCase() === 'connectivity_failed') {
return false;
}
return true;
}
function buildTargetReachabilityFailureMessage(status = {}, reachability = {}) {
const exitIp = String(status?.exitIp || '').trim();
const exitRegion = String(status?.exitRegion || '').trim();
const endpoint = String(reachability?.endpoint || reachability?.url || IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0] || '').trim();
const targetHost = extractProbeHostFromTabUrl(endpoint) || endpoint || 'chatgpt.com';
const diagnostic = String(reachability?.error || reachability?.diagnostics || '').trim();
const diagnosticSuffix = diagnostic ? ` 诊断:${diagnostic}` : '';
const exitRegionSuffix = exitRegion ? ` [${exitRegion}]` : '';
return `已检测到出口 IP ${exitIp}${exitRegionSuffix},但真实目标 ${targetHost} 不可达。`
+ `这说明代理只通过了 IP 探测,无法打开步骤 1 的 ChatGPT 页面;请更换支持 chatgpt.com CONNECT/TLS 的节点。`
+ diagnosticSuffix;
}
function applyTargetReachabilityExpectation(status = {}, reachability = {}) {
if (!shouldVerifyIpProxyTargetReachability(status)) {
return status;
}
if (reachability?.reachable === true) {
return status;
}
if (reachability?.skipped === true) {
return status;
}
return {
...status,
applied: false,
reason: 'connectivity_failed',
warning: '',
error: buildTargetReachabilityFailureMessage(status, reachability),
};
}
async function detectProxyExitInfoByBackgroundFetch(options = {}) {
const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000;
const errors = Array.isArray(options?.errors) ? options.errors : [];
@@ -1954,6 +2029,35 @@ async function readExitProbeFromTabDocument(tabId) {
return executionResults?.[0]?.result || null;
}
function appendProbeCacheBuster(rawUrl = '', key = '_multipage_proxy_probe') {
const text = String(rawUrl || '').trim();
if (!text) {
return '';
}
try {
const parsed = new URL(text);
parsed.searchParams.set(key, String(Date.now()));
return parsed.toString();
} catch {
const separator = text.includes('?') ? '&' : '?';
return `${text}${separator}${key}=${Date.now()}`;
}
}
async function readTargetReachabilityFromTabDocument(tabId) {
const executionResults = await chrome.scripting.executeScript({
target: { tabId },
world: 'ISOLATED',
func: () => ({
href: String(location.href || ''),
title: String(document.title || ''),
readyState: String(document.readyState || ''),
bodyLength: Number(document.body?.innerText?.length || document.documentElement?.innerText?.length || 0),
}),
});
return executionResults?.[0]?.result || null;
}
async function probeExitInfoByTabNavigation(tabId, timeoutMs = 10000, errors = []) {
return probeExitInfoByTabNavigationWithEndpoints(tabId, timeoutMs, errors, []);
}
@@ -2208,6 +2312,101 @@ async function probeExitInfoViaExecuteScript(tabId, timeoutMs = 10000, endpoints
return executionResults?.[0]?.result || null;
}
async function detectIpProxyTargetReachabilityByPageContext(options = {}) {
const timeoutMs = Number(options?.timeoutMs) > 0
? Number(options.timeoutMs)
: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS;
const errors = Array.isArray(options?.errors) ? options.errors : [];
const endpoints = resolveTargetReachabilityEndpoints(options);
if (!endpoints.length) {
return { reachable: true, skipped: true, source: 'target_page_context', endpoint: '' };
}
if (!chrome.tabs?.create || !chrome.tabs?.update || !chrome.scripting?.executeScript) {
errors.push('target:page_context:unavailable');
return {
reachable: false,
endpoint: endpoints[0],
source: 'target_page_context_unavailable',
error: 'target:page_context:unavailable',
};
}
let tabId = null;
let createdTabId = null;
const navigationErrorTracker = createProbeNavigationErrorTracker(null);
const perEndpointTimeoutMs = Math.max(2500, Math.min(IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS, timeoutMs));
try {
for (let index = 0; index < endpoints.length; index += 1) {
const endpoint = String(endpoints[index] || '').trim();
if (!endpoint) {
continue;
}
const targetUrl = appendProbeCacheBuster(endpoint, '_multipage_proxy_target');
try {
if (!Number.isInteger(tabId)) {
const tab = await chrome.tabs.create({
url: targetUrl,
active: false,
});
tabId = Number(tab?.id) || null;
createdTabId = tabId;
navigationErrorTracker.setTabId(tabId);
} else {
await chrome.tabs.update(tabId, {
url: targetUrl,
active: false,
});
}
if (!Number.isInteger(tabId)) {
errors.push(`target:page_context:${endpoint}:no_tab`);
continue;
}
const ready = await waitForPageContextProbeTabReady(tabId, perEndpointTimeoutMs);
if (!ready) {
errors.push(`target:page_context:${endpoint}:not_ready`);
continue;
}
const documentResult = await readTargetReachabilityFromTabDocument(tabId);
const href = String(documentResult?.href || '').trim();
const host = extractProbeHostFromTabUrl(href);
const expectedHost = extractProbeHostFromTabUrl(endpoint);
if (host && expectedHost && (host === expectedHost || host.endsWith(`.${expectedHost}`))) {
return {
reachable: true,
endpoint,
url: href,
source: 'target_page_context',
};
}
errors.push(`target:page_context:${endpoint}:unexpected_url:${href || 'unknown'}`);
} catch (error) {
errors.push(`target:page_context:${endpoint}:${error?.message || error}`);
}
}
} finally {
navigationErrorTracker.appendDiagnostics(errors, 3);
navigationErrorTracker.dispose();
if (Number.isInteger(createdTabId)) {
try {
await chrome.tabs.remove(createdTabId);
} catch {
// ignore tab close failures
}
}
}
return {
reachable: false,
endpoint: endpoints[0],
source: 'target_page_context',
error: buildProbeDiagnosticsSummary(errors, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS),
};
}
async function detectProxyExitInfoByPageContext(options = {}) {
const timeoutMs = Number(options?.timeoutMs) > 0 ? Number(options.timeoutMs) : 10000;
const errors = Array.isArray(options?.errors) ? options.errors : [];
@@ -2675,6 +2874,16 @@ function buildIpProxyPacScript(entry) {
}
const targetPatterns = IP_PROXY_TARGET_HOST_PATTERNS.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`).join(', ');
const bypassList = IP_PROXY_BYPASS_LIST.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`).join(', ');
const forceDirectPatterns = (typeof IP_PROXY_FORCE_DIRECT_HOST_PATTERNS !== 'undefined' && Array.isArray(IP_PROXY_FORCE_DIRECT_HOST_PATTERNS)
? IP_PROXY_FORCE_DIRECT_HOST_PATTERNS
: [])
.map((pattern) => `'${String(pattern).replace(/'/g, "\\'")}'`)
.join(', ');
const forceDirectFallback = String(
typeof IP_PROXY_FORCE_DIRECT_FALLBACK !== 'undefined' && IP_PROXY_FORCE_DIRECT_FALLBACK
? IP_PROXY_FORCE_DIRECT_FALLBACK
: 'DIRECT'
).replace(/"/g, '\\"');
const proxyEndpoint = `${pacScheme} ${host}:${port}`;
const routeAllLiteral = (typeof IP_PROXY_ROUTE_ALL_TRAFFIC !== 'undefined' && Boolean(IP_PROXY_ROUTE_ALL_TRAFFIC))
? 'true'
@@ -2690,6 +2899,22 @@ function FindProxyForURL(url, host) {
}
}
var forceDirectPatterns = [${forceDirectPatterns}];
for (var fd = 0; fd < forceDirectPatterns.length; fd++) {
var directPattern = forceDirectPatterns[fd];
if (directPattern.indexOf('*.') === 0) {
var directSuffix = directPattern.substring(1);
var directHost = directPattern.substring(2);
if (dnsDomainIs(host, directSuffix) || host === directHost) {
return "${forceDirectFallback}";
}
continue;
}
if (host === directPattern || dnsDomainIs(host, '.' + directPattern)) {
return "${forceDirectFallback}";
}
}
var routeAllTraffic = ${routeAllLiteral};
if (routeAllTraffic) {
return "${proxyEndpoint}";
@@ -3109,9 +3334,25 @@ async function applyIpProxySettingsFromState(state = {}, options = {}) {
const expectedRegion = String(entry?.region || '').trim();
let normalizedExitStatus = applyExitRegionExpectation(exitStatus, expectedRegion);
normalizedExitStatus = applyExitBaselineExpectation(normalizedExitStatus);
if (normalizedExitStatus.reason === 'connectivity_failed') {
await setIpProxyLeakGuardEnabled(true);
if (shouldVerifyIpProxyTargetReachability(normalizedExitStatus)) {
const targetDiagnostics = [];
const reachability = await detectIpProxyTargetReachabilityByPageContext({
timeoutMs: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS,
errors: targetDiagnostics,
}).catch((error) => ({
reachable: false,
endpoint: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0],
source: 'target_page_context',
error: error?.message || String(error || 'target reachability failed'),
}));
normalizedExitStatus = applyTargetReachabilityExpectation(normalizedExitStatus, {
...reachability,
error: reachability?.reachable
? ''
: (reachability?.error || buildProbeDiagnosticsSummary(targetDiagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS)),
});
}
await syncIpProxyLeakGuardForStatus(normalizedExitStatus);
await updateIpProxyRuntimeStatus(normalizedExitStatus);
return normalizedExitStatus;
}
@@ -3425,6 +3666,32 @@ async function probeIpProxyExit(options = {}) {
const probePromise = (async () => {
const state = options.state || await getState();
if (!state?.ipProxyEnabled) {
if (options?.detectWhenDisabled) {
const diagnostics = [];
const exit = await detectProxyExitInfo({
timeoutMs: Number(options?.timeoutMs) || 10000,
errors: diagnostics,
provider: normalizeIpProxyProviderValue(state?.ipProxyService),
username: String(state?.ipProxyUsername || '').trim(),
preferPageContext: true,
allowBackgroundFallback: true,
}).catch(() => ({ ip: '', region: '', endpoint: '', source: '' }));
const status = {
enabled: false,
applied: false,
reason: 'disabled_probe_only',
provider: normalizeIpProxyProviderValue(state?.ipProxyService),
exitDetecting: false,
exitIp: String(exit?.ip || '').trim(),
exitRegion: String(exit?.region || '').trim(),
exitBaselineIp: String(exit?.baselineIp || '').trim(),
exitEndpoint: String(exit?.endpoint || '').trim(),
exitSource: String(exit?.source || '').trim().toLowerCase(),
exitError: exit?.ip ? '' : buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS),
error: '',
};
return { proxyRouting: status };
}
const status = {
enabled: false,
applied: false,
@@ -3488,6 +3755,7 @@ async function probeIpProxyExit(options = {}) {
exitDetecting: true,
exitIp: '',
exitRegion: '',
exitEndpoint: '',
exitError: '',
exitSource: '',
};
@@ -3519,6 +3787,7 @@ async function probeIpProxyExit(options = {}) {
exitIp: String(exit?.ip || '').trim(),
exitRegion: String(exit?.region || '').trim(),
exitBaselineIp: String(exit?.baselineIp || '').trim(),
exitEndpoint: String(exit?.endpoint || '').trim(),
exitError: exit?.ip ? '' : buildProbeDiagnosticsSummary(diagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS),
exitSource: String(exit?.source || '').trim().toLowerCase(),
authDiagnostics: statusSeed?.hasAuth ? getIpProxyAuthDiagnosticsSummary() : '',
@@ -3526,6 +3795,24 @@ async function probeIpProxyExit(options = {}) {
const expectedRegion = String(statusSeed?.region || '').trim();
let normalized = applyExitRegionExpectation(finalStatus, expectedRegion);
normalized = applyExitBaselineExpectation(normalized);
if (shouldVerifyIpProxyTargetReachability(normalized)) {
const targetDiagnostics = [];
const reachability = await detectIpProxyTargetReachabilityByPageContext({
timeoutMs: IP_PROXY_TARGET_REACHABILITY_TIMEOUT_MS,
errors: targetDiagnostics,
}).catch((error) => ({
reachable: false,
endpoint: IP_PROXY_TARGET_REACHABILITY_ENDPOINTS[0],
source: 'target_page_context',
error: error?.message || String(error || 'target reachability failed'),
}));
normalized = applyTargetReachabilityExpectation(normalized, {
...reachability,
error: reachability?.reachable
? ''
: (reachability?.error || buildProbeDiagnosticsSummary(targetDiagnostics, IP_PROXY_DIAGNOSTICS_SUMMARY_MAX_ITEMS)),
});
}
return normalized;
};
@@ -3584,6 +3871,7 @@ async function probeIpProxyExit(options = {}) {
exitDetecting: true,
exitIp: '',
exitRegion: '',
exitEndpoint: '',
exitError: '',
exitSource: '',
};
@@ -3612,9 +3900,7 @@ async function probeIpProxyExit(options = {}) {
normalizedFinalStatus = recovered.status;
}
}
if (normalizedFinalStatus.reason === 'connectivity_failed') {
await setIpProxyLeakGuardEnabled(true);
}
await syncIpProxyLeakGuardForStatus(normalizedFinalStatus);
await updateIpProxyRuntimeStatus(normalizedFinalStatus);
return { proxyRouting: normalizedFinalStatus };
})();
+22 -2
View File
@@ -31,10 +31,28 @@
return labels[source] || source || '未知来源';
}
async function addLog(message, level = 'info') {
function normalizeLogStep(value) {
const step = Math.floor(Number(value) || 0);
return step > 0 ? step : null;
}
function buildLogEntry(message, level = 'info', options = {}) {
const normalizedOptions = options && typeof options === 'object' ? options : {};
const step = normalizeLogStep(normalizedOptions.step);
const stepKey = String(normalizedOptions.stepKey || '').trim();
return {
message: String(message || ''),
level,
timestamp: Date.now(),
step,
stepKey,
};
}
async function addLog(message, level = 'info', options = {}) {
const state = await getState();
const logs = state.logs || [];
const entry = { message, level, timestamp: Date.now() };
const entry = buildLogEntry(message, level, options);
logs.push(entry);
if (logs.length > 500) logs.splice(0, logs.length - 500);
await setState({ logs });
@@ -83,6 +101,8 @@
return 'OAuth 授权页';
case 'add_phone_page':
return '手机号页';
case 'add_email_page':
return '添加邮箱页';
case 'phone_verification_page':
return '手机验证码页';
default:
+267 -32
View File
@@ -33,6 +33,7 @@
executeStepViaCompletionSignal,
exportSettingsBundle,
fetchGeneratedEmail,
refreshGpcCardBalance,
finalizePhoneActivationAfterSuccessfulFlow,
finalizeStep3Completion,
finalizeIcloudAliasAfterSuccessfulFlow,
@@ -48,6 +49,14 @@
getStepDefinitionForState,
getStepIdsForState,
getLastStepIdForState,
normalizeSignupMethod = (value = '') => String(value || '').trim().toLowerCase() === 'phone' ? 'phone' : 'email',
canUsePhoneSignup = (state = {}) => Boolean(state?.phoneVerificationEnabled)
&& !Boolean(state?.plusModeEnabled)
&& !Boolean(state?.contributionMode),
resolveSignupMethod = (state = {}) => {
const method = normalizeSignupMethod(state?.signupMethod);
return method === 'phone' && canUsePhoneSignup(state) ? 'phone' : 'email';
},
getTabId,
getStopRequested,
handleAutoRunLoopUnhandledError,
@@ -66,7 +75,8 @@
runIpProxyAutoSync,
listIcloudAliases,
listLuckmailPurchasesForManagement,
refreshIpProxyPool,
markCurrentCustomEmailPoolEntryUsed,
markCurrentRegistrationAccountUsed,
normalizeHotmailAccounts,
normalizeMail2925Accounts,
normalizePayPalAccounts,
@@ -93,6 +103,8 @@
setContributionMode,
setEmailState,
setEmailStateSilently,
setSignupPhoneState,
setSignupPhoneStateSilently,
setIcloudAliasPreservedState,
setIcloudAliasUsedState,
setLuckmailPurchaseDisabledState,
@@ -153,6 +165,74 @@
return '';
}
function resolveSignupPhonePayload(payload = {}) {
const directPhone = String(
payload?.signupPhoneNumber
|| payload?.phoneNumber
|| ''
).trim();
if (directPhone) {
return directPhone;
}
return String(payload?.accountIdentifierType || '').trim().toLowerCase() === 'phone'
? String(payload?.accountIdentifier || '').trim()
: '';
}
function resolveEmailIdentityPayload(payload = {}) {
const directEmail = String(payload?.email || '').trim();
if (directEmail) {
return directEmail;
}
return String(payload?.accountIdentifierType || '').trim().toLowerCase() === 'email'
? String(payload?.accountIdentifier || '').trim()
: '';
}
async function syncStepAccountIdentityFromPayload(payload = {}) {
const identifierType = String(payload?.accountIdentifierType || '').trim().toLowerCase();
const signupPhoneNumber = resolveSignupPhonePayload(payload);
if (identifierType === 'phone' || signupPhoneNumber) {
if (signupPhoneNumber) {
await setSignupPhoneStateSilently(signupPhoneNumber);
}
const updates = {};
if (Object.prototype.hasOwnProperty.call(payload, 'signupPhoneActivation')) {
updates.signupPhoneActivation = payload.signupPhoneActivation || null;
}
if (Object.prototype.hasOwnProperty.call(payload, 'signupPhoneCompletedActivation')) {
updates.signupPhoneCompletedActivation = payload.signupPhoneCompletedActivation || null;
}
if (Object.keys(updates).length) {
await setState(updates);
broadcastDataUpdate(updates);
}
return;
}
const email = resolveEmailIdentityPayload(payload);
if (identifierType === 'email' || email) {
if (email) {
await setEmailState(email);
}
const updates = {
phoneNumber: '',
signupPhoneNumber: '',
signupPhoneActivation: null,
signupPhoneCompletedActivation: null,
signupPhoneVerificationRequestedAt: null,
signupPhoneVerificationPurpose: '',
...(email ? {
accountIdentifierType: 'email',
accountIdentifier: email,
} : {}),
};
await setSignupPhoneStateSilently(null);
await setState(updates);
broadcastDataUpdate(updates);
}
}
function isStepProtectedFromAutoSkip(status) {
return status === 'running'
|| status === 'completed'
@@ -178,26 +258,47 @@
}) || null;
}
function normalizePlusPaymentMethodForDisplay(value = '') {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'gpc-helper') {
return 'gpc-helper';
}
return normalized === 'gopay' ? 'gopay' : 'paypal';
}
function getPlusPaymentMethodLabel(value = '') {
const method = normalizePlusPaymentMethodForDisplay(value);
if (method === 'gpc-helper') {
return 'GPC';
}
return method === 'gopay' ? 'GoPay' : 'PayPal';
}
async function handlePlatformVerifyStepData(payload) {
if (payload.localhostUrl) {
await closeLocalhostCallbackTabs(payload.localhostUrl);
}
const latestState = await getState();
if (latestState.currentHotmailAccountId && isHotmailProvider(latestState)) {
if (typeof markCurrentRegistrationAccountUsed === 'function') {
await markCurrentRegistrationAccountUsed(latestState, {
logPrefix: '流程完成',
level: 'ok',
});
} else if (latestState.currentHotmailAccountId && isHotmailProvider(latestState)) {
await patchHotmailAccount(latestState.currentHotmailAccountId, {
used: true,
lastUsedAt: Date.now(),
});
await addLog('当前 Hotmail 账号已自动标记为已用。', 'ok');
}
if (String(latestState.mailProvider || '').trim().toLowerCase() === '2925' && latestState.currentMail2925AccountId) {
if (typeof markCurrentRegistrationAccountUsed !== 'function' && String(latestState.mailProvider || '').trim().toLowerCase() === '2925' && latestState.currentMail2925AccountId) {
await patchMail2925Account(latestState.currentMail2925AccountId, {
lastUsedAt: Date.now(),
lastError: '',
});
await addLog('当前 2925 账号已记录最近使用时间。', 'ok');
}
if (isLuckmailProvider(latestState)) {
if (typeof markCurrentRegistrationAccountUsed !== 'function' && isLuckmailProvider(latestState)) {
const currentPurchase = getCurrentLuckmailPurchase(latestState);
if (currentPurchase?.id) {
await setLuckmailPurchaseUsedState(currentPurchase.id, true);
@@ -213,7 +314,9 @@
excludeLocalhostCallbacks: true,
});
}
if (typeof markCurrentRegistrationAccountUsed !== 'function') {
await finalizeIcloudAliasAfterSuccessfulFlow(latestState);
}
if (typeof finalizePhoneActivationAfterSuccessfulFlow === 'function') {
await finalizePhoneActivationAfterSuccessfulFlow(latestState);
}
@@ -253,7 +356,10 @@
const currentStatus = latestState.stepStatuses?.[loginCodeStep];
if (!isStepProtectedFromAutoSkip(currentStatus)) {
await setStepStatus(loginCodeStep, 'skipped');
await addLog(`步骤 ${step}认证页已直接进入 OAuth 授权页,已自动跳过步骤 ${loginCodeStep} 的登录验证码。`, 'warn');
await addLog(`认证页已直接进入 OAuth 授权页,已自动跳过步骤 ${loginCodeStep} 的登录验证码。`, 'warn', {
step,
stepKey: 'oauth-login',
});
}
}
} else if (payload.loginVerificationRequestedAt) {
@@ -264,7 +370,13 @@
if (stepKey === 'fetch-login-code') {
await setState({
...(payload.phoneVerification || payload.loginPhoneVerification ? {
currentPhoneVerificationCode: '',
signupPhoneVerificationRequestedAt: null,
signupPhoneVerificationPurpose: '',
} : {
lastEmailTimestamp: payload.emailTimestamp || null,
}),
loginVerificationRequestedAt: null,
});
return;
@@ -287,10 +399,29 @@
}
switch (step) {
case 2:
if (payload.email) {
await setEmailState(payload.email);
case 1: {
const updates = {};
if (payload.oauthUrl) {
updates.oauthUrl = payload.oauthUrl;
broadcastDataUpdate({ oauthUrl: payload.oauthUrl });
}
if (payload.sub2apiSessionId !== undefined) updates.sub2apiSessionId = payload.sub2apiSessionId || null;
if (payload.sub2apiOAuthState !== undefined) updates.sub2apiOAuthState = payload.sub2apiOAuthState || null;
if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
if (payload.sub2apiGroupIds !== undefined) updates.sub2apiGroupIds = Array.isArray(payload.sub2apiGroupIds)
? payload.sub2apiGroupIds
: [];
if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null;
if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null;
if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null;
if (Object.keys(updates).length) {
await setState(updates);
}
break;
}
case 2:
await syncStepAccountIdentityFromPayload(payload);
if (payload.skipRegistrationFlow) {
const latestState = await getState();
for (const skipStep of [3, 4, 5]) {
@@ -308,22 +439,37 @@
const step3Status = latestState.stepStatuses?.[3];
if (step3Status !== 'running' && step3Status !== 'completed' && step3Status !== 'manual_completed') {
await setStepStatus(3, 'skipped');
await addLog('步骤 2:提交邮箱后页面直接进入邮箱验证码页,已自动跳过步骤 3。', 'warn');
const identityLabel = payload.accountIdentifierType === 'phone' ? '手机号' : '邮箱';
await addLog(`步骤 2:提交${identityLabel}后页面直接进入验证码页,已自动跳过步骤 3。`, 'warn');
}
}
break;
case 3:
if (payload.email) await setEmailState(payload.email);
await syncStepAccountIdentityFromPayload(payload);
if (payload.signupVerificationRequestedAt) {
await setState({ signupVerificationRequestedAt: payload.signupVerificationRequestedAt });
}
if (payload.skipProfileStep) {
const latestState = await getState();
const step5Status = latestState.stepStatuses?.[5];
if (step5Status !== 'running' && step5Status !== 'completed' && step5Status !== 'manual_completed') {
await setStepStatus(5, 'skipped');
await addLog('步骤 3:页面已直接进入已登录态,已自动跳过步骤 5。', 'warn');
}
}
if (payload.loginVerificationRequestedAt) {
await setState({ loginVerificationRequestedAt: payload.loginVerificationRequestedAt });
}
break;
case 4:
await setState({
...(payload.phoneVerification ? {
currentPhoneVerificationCode: '',
signupPhoneVerificationRequestedAt: null,
signupPhoneVerificationPurpose: '',
} : {
lastEmailTimestamp: payload.emailTimestamp || null,
}),
signupVerificationRequestedAt: null,
});
if (payload.skipProfileStep) {
@@ -339,6 +485,27 @@
}
}
break;
case 8:
await setState({
...(payload.phoneVerification || payload.loginPhoneVerification ? {
currentPhoneVerificationCode: '',
signupPhoneVerificationRequestedAt: null,
signupPhoneVerificationPurpose: '',
} : {
lastEmailTimestamp: payload.emailTimestamp || null,
}),
loginVerificationRequestedAt: null,
});
break;
case 9:
if (payload.localhostUrl) {
if (!isLocalhostOAuthCallbackUrl(payload.localhostUrl)) {
throw new Error('步骤 9 返回了无效的 localhost OAuth 回调地址。');
}
await setState({ localhostUrl: payload.localhostUrl });
broadcastDataUpdate({ localhostUrl: payload.localhostUrl });
}
break;
default:
break;
}
@@ -357,8 +524,16 @@
}
case 'LOG': {
const { message: msg, level } = message.payload;
await addLog(`[${getSourceLabel(message.source)}] ${msg}`, level);
const { message: msg, level, step: payloadStep, stepKey } = message.payload;
const logStep = Math.floor(Number(message.step || payloadStep) || 0);
await addLog(
`[${getSourceLabel(message.source)}] ${msg}`,
level,
{
step: logStep > 0 ? logStep : null,
stepKey,
}
);
return { ok: true };
}
@@ -383,7 +558,7 @@
}
const errorMessage = error?.message || String(error || '步骤 3 提交后确认失败');
await setStepStatus(message.step, 'failed');
await addLog(`步骤 ${message.step} 失败:${errorMessage}`, 'error');
await addLog(`失败:${errorMessage}`, 'error', { step: message.step });
await appendManualAccountRunRecordIfNeeded(`step${message.step}_failed`, null, errorMessage);
notifyStepError(message.step, errorMessage);
return { ok: true, error: errorMessage };
@@ -395,7 +570,7 @@
: 10;
const completionState = message.step === lastStepId ? completionStateCandidate : null;
await setStepStatus(message.step, 'completed');
await addLog(`步骤 ${message.step} 已完成`, 'ok');
await addLog('已完成', 'ok', { step: message.step });
await handleStepData(message.step, message.payload);
if (message.step === lastStepId && typeof appendAccountRunRecord === 'function') {
await appendAccountRunRecord('success', completionState);
@@ -414,12 +589,12 @@
}
if (isStopError(message.error)) {
await setStepStatus(message.step, 'stopped');
await addLog(`步骤 ${message.step} 已被用户停止`, 'warn');
await addLog('已被用户停止', 'warn', { step: message.step });
await appendManualAccountRunRecordIfNeeded(`step${message.step}_stopped`, null, message.error);
notifyStepError(message.step, message.error);
} else {
await setStepStatus(message.step, 'failed');
await addLog(`步骤 ${message.step} 失败:${message.error}`, 'error');
await addLog(`失败:${message.error}`, 'error', { step: message.step });
await appendManualAccountRunRecordIfNeeded(`step${message.step}_failed`, null, message.error);
notifyStepError(message.step, message.error);
}
@@ -432,6 +607,8 @@
const confirmed = Boolean(message.payload?.confirmed);
const requestId = String(message.payload?.requestId || '').trim();
const currentRequestId = String(currentState?.plusManualConfirmationRequestId || '').trim();
const method = String(currentState?.plusManualConfirmationMethod || '').trim().toLowerCase();
const isGpcOtp = method === 'gopay-otp';
if (!currentState?.plusManualConfirmationPending) {
return { ok: true, ignored: true };
}
@@ -447,15 +624,31 @@
plusManualConfirmationTitle: '',
plusManualConfirmationMessage: '',
};
if (isGpcOtp && confirmed) {
const otp = String(message.payload?.otp || message.payload?.code || '').trim().replace(/[^\d]/g, '');
if (!otp) {
throw new Error('请输入 GPC OTP 验证码。');
}
const otpUpdates = {
...clearManualConfirmationState,
gopayHelperResolvedOtp: otp,
};
await setState(otpUpdates);
if (typeof broadcastDataUpdate === 'function') {
broadcastDataUpdate(otpUpdates);
}
await addLog(`步骤 ${step}:已收到 GPC OTP,准备提交验证。`, 'ok');
return { ok: true };
}
await setState(clearManualConfirmationState);
if (typeof broadcastDataUpdate === 'function') {
broadcastDataUpdate(clearManualConfirmationState);
}
if (confirmed) {
const methodLabel = String(currentState?.plusManualConfirmationMethod || '').trim().toLowerCase() === 'gopay'
? 'GoPay'
: '手动';
const methodLabel = method === 'gopay' ? 'GoPay' : '手动';
await addLog(`步骤 ${step}:已确认${methodLabel}订阅完成,准备继续下一步。`, 'ok');
await completeStepFromBackground(step, {
plusManualConfirmationMethod: currentState?.plusManualConfirmationMethod || '',
@@ -464,9 +657,9 @@
return { ok: true };
}
const cancelMessage = String(currentState?.plusManualConfirmationMethod || '').trim().toLowerCase() === 'gopay'
const cancelMessage = method === 'gopay'
? '已取消 GoPay 订阅确认'
: '已取消当前手动确认';
: (isGpcOtp ? '已取消 GPC OTP 输入' : '已取消当前手动确认');
await setStepStatus(step, 'failed');
await addLog(`步骤 ${step}${cancelMessage}`, 'warn');
await appendManualAccountRunRecordIfNeeded(`step${step}_failed`, null, cancelMessage);
@@ -703,11 +896,23 @@
const currentState = await getState();
const updates = buildPersistentSettingsPayload(message.payload || {});
const sessionUpdates = buildLuckmailSessionSettingsPayload(message.payload || {});
const nextSignupState = {
...currentState,
...updates,
resolvedSignupMethod: null,
};
if (
Object.prototype.hasOwnProperty.call(updates, 'phoneVerificationEnabled')
|| Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled')
|| Object.prototype.hasOwnProperty.call(updates, 'signupMethod')
) {
updates.signupMethod = resolveSignupMethod(nextSignupState);
}
const modeChanged = Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled')
&& Boolean(currentState?.plusModeEnabled) !== Boolean(updates.plusModeEnabled);
const plusPaymentChanged = Object.prototype.hasOwnProperty.call(updates, 'plusPaymentMethod')
&& String(currentState?.plusPaymentMethod || 'paypal').trim().toLowerCase()
!== String(updates.plusPaymentMethod || 'paypal').trim().toLowerCase();
&& normalizePlusPaymentMethodForDisplay(currentState?.plusPaymentMethod || 'paypal')
!== normalizePlusPaymentMethodForDisplay(updates.plusPaymentMethod || 'paypal');
const nextPlusModeEnabled = Object.prototype.hasOwnProperty.call(updates, 'plusModeEnabled')
? Boolean(updates.plusModeEnabled)
: Boolean(currentState?.plusModeEnabled);
@@ -782,11 +987,9 @@
await setContributionMode(true);
}
if (modeChanged) {
const selectedPlusPaymentMethod = String(
(stateUpdates.plusPaymentMethod ?? currentState?.plusPaymentMethod ?? 'paypal')
).trim().toLowerCase() === 'gopay'
? 'GoPay'
: 'PayPal';
const selectedPlusPaymentMethod = getPlusPaymentMethodLabel(
stateUpdates.plusPaymentMethod ?? currentState?.plusPaymentMethod ?? 'paypal'
);
await addLog(
Boolean(updates.plusModeEnabled)
? `Plus 模式已开启,已切换为 Plus Checkout 步骤,当前支付方式:${selectedPlusPaymentMethod}`
@@ -794,16 +997,28 @@
'info'
);
} else if (plusPaymentChanged && nextPlusModeEnabled) {
const selectedPlusPaymentMethod = String(
const selectedPlusPaymentMethod = getPlusPaymentMethodLabel(
stateUpdates.plusPaymentMethod ?? currentState?.plusPaymentMethod ?? 'paypal'
).trim().toLowerCase() === 'gopay'
? 'GoPay'
: 'PayPal';
);
await addLog(`Plus 支付方式已切换为 ${selectedPlusPaymentMethod},已更新对应的 Plus 步骤。`, 'info');
}
return { ok: true, state: await getState(), proxyRouting };
}
case 'REFRESH_GPC_CARD_BALANCE': {
if (typeof refreshGpcCardBalance !== 'function') {
throw new Error('GPC 卡密余额查询能力尚未接入。');
}
const state = await getState();
const result = await refreshGpcCardBalance({
...(state || {}),
...(message.payload || {}),
}, {
reason: message.payload?.reason,
});
return { ok: true, ...result };
}
case 'RUN_IP_PROXY_AUTO_SYNC_NOW': {
if (typeof runIpProxyAutoSync !== 'function') {
throw new Error('IP 代理自动同步能力尚未接入。');
@@ -1066,6 +1281,26 @@
return { ok: true, email: message.payload.email };
}
case 'SET_SIGNUP_PHONE_STATE': {
const state = await getState();
if (isAutoRunLockedState(state)) {
throw new Error('自动流程运行中,当前不能手动修改注册手机号。');
}
const phoneNumber = resolveSignupPhonePayload(message.payload) || null;
await setSignupPhoneStateSilently(phoneNumber);
return { ok: true, phoneNumber };
}
case 'SAVE_SIGNUP_PHONE': {
const state = await getState();
if (isAutoRunLockedState(state)) {
throw new Error('自动流程运行中,当前不能手动修改注册手机号。');
}
const phoneNumber = resolveSignupPhonePayload(message.payload) || null;
await setSignupPhoneState(phoneNumber);
return { ok: true, phoneNumber };
}
case 'FETCH_GENERATED_EMAIL': {
clearStopRequest();
const state = await getState();
+1 -1
View File
@@ -72,7 +72,7 @@
const parsed = parseUrlSafely(rawUrl);
if (!parsed) return false;
return isSignupPageHost(parsed.hostname)
&& /\/create-account\/password(?:[/?#]|$)/i.test(parsed.pathname || '');
&& /\/(?:create-account|log-in)\/password(?:[/?#]|$)/i.test(parsed.pathname || '');
}
function isSignupEmailVerificationPageUrl(rawUrl) {
File diff suppressed because it is too large Load Diff
+120 -14
View File
@@ -10,6 +10,7 @@
ensureHotmailAccountForFlow,
ensureMail2925AccountForFlow,
ensureLuckmailPurchaseForFlow,
fetchGeneratedEmail,
isGeneratedAliasProvider,
isReusableGeneratedAliasEmail,
isHotmailProvider,
@@ -17,11 +18,15 @@
isLuckmailProvider,
isSignupEmailVerificationPageUrl,
isSignupPasswordPageUrl,
isSignupPhoneVerificationPageUrl = null,
isSignupProfilePageUrl = null,
reuseOrCreateTab,
sendToContentScriptResilient,
setEmailState,
setState,
SIGNUP_ENTRY_URL,
SIGNUP_PAGE_INJECT_FILES,
waitForTabStableComplete = null,
waitForTabUrlMatch,
} = deps;
@@ -62,46 +67,95 @@
return { tabId, result: result || {} };
}
function resolveSignupPostEmailState(rawUrl) {
function parseUrlSafely(rawUrl) {
if (!rawUrl) return null;
try {
return new URL(rawUrl);
} catch {
return null;
}
}
function fallbackSignupPhoneVerificationPageUrl(rawUrl) {
const parsed = parseUrlSafely(rawUrl);
if (!parsed) return false;
return /\/phone-verification(?:[/?#]|$)/i.test(parsed.pathname || '');
}
function fallbackSignupProfilePageUrl(rawUrl) {
const parsed = parseUrlSafely(rawUrl);
if (!parsed) return false;
return /\/create-account\/profile(?:[/?#]|$)/i.test(parsed.pathname || '');
}
function resolveSignupPostIdentityState(rawUrl) {
if (isSignupPasswordPageUrl(rawUrl)) {
return 'password_page';
}
if (isSignupEmailVerificationPageUrl(rawUrl)) {
return 'verification_page';
}
const isPhoneVerificationUrl = typeof isSignupPhoneVerificationPageUrl === 'function'
? isSignupPhoneVerificationPageUrl(rawUrl)
: fallbackSignupPhoneVerificationPageUrl(rawUrl);
if (isPhoneVerificationUrl) {
return 'phone_verification_page';
}
const isProfileUrl = typeof isSignupProfilePageUrl === 'function'
? isSignupProfilePageUrl(rawUrl)
: fallbackSignupProfilePageUrl(rawUrl);
if (isProfileUrl) {
return 'profile_page';
}
return '';
}
async function ensureSignupPostEmailPageReadyInTab(tabId, step = 2, options = {}) {
async function ensureSignupPostIdentityPageReadyInTab(tabId, step = 2, options = {}) {
const { skipUrlWait = false } = options;
let landingUrl = '';
let landingState = '';
if (!skipUrlWait) {
const matchedTab = await waitForTabUrlMatch(tabId, (url) => Boolean(resolveSignupPostEmailState(url)), {
const matchedTab = await waitForTabUrlMatch(tabId, (url) => Boolean(resolveSignupPostIdentityState(url)), {
timeoutMs: 45000,
retryDelayMs: 300,
});
if (!matchedTab) {
throw new Error('等待邮箱提交后的页面跳转超时,请检查页面是否仍停留在邮箱输入页。');
throw new Error('等待注册身份提交后的页面跳转超时,请检查页面是否仍停留在输入页。');
}
landingUrl = matchedTab.url || '';
landingState = resolveSignupPostEmailState(landingUrl);
landingState = resolveSignupPostIdentityState(landingUrl);
}
if (!landingState) {
try {
const currentTab = await chrome.tabs.get(tabId);
landingUrl = landingUrl || currentTab?.url || '';
landingState = resolveSignupPostEmailState(landingUrl);
landingState = resolveSignupPostIdentityState(landingUrl);
} catch {
landingUrl = landingUrl || '';
}
}
if (!landingState) {
throw new Error(`邮箱提交后未能识别当前页面,既不是密码页也不是邮箱验证码页。URL: ${landingUrl || 'unknown'}`);
throw new Error(`注册身份提交后未能识别当前页面,既不是密码页、验证码页,也不是资料页。URL: ${landingUrl || 'unknown'}`);
}
if (landingState !== 'password_page' && typeof waitForTabStableComplete === 'function') {
const stableTab = await waitForTabStableComplete(tabId, {
timeoutMs: 45000,
retryDelayMs: 300,
stableMs: 800,
initialDelayMs: 300,
});
if (stableTab?.url) {
const stableState = resolveSignupPostIdentityState(stableTab.url);
if (stableState) {
landingUrl = stableTab.url;
landingState = stableState;
}
}
}
await ensureContentScriptReadyOnTab('signup-page', tabId, {
@@ -109,12 +163,12 @@
injectSource: 'signup-page',
timeoutMs: 45000,
retryDelayMs: 900,
logMessage: landingState === 'verification_page'
? `步骤 ${step}邮箱验证码页仍在加载,正在等待页面恢复...`
: `步骤 ${step}密码页仍在加载,正在重试连接内容脚本...`,
logMessage: landingState === 'password_page'
? `步骤 ${step}码页仍在加载,正在重试连接内容脚本...`
: `步骤 ${step}注册后续页面仍在加载,正在等待页面恢复...`,
});
if (landingState === 'verification_page') {
if (landingState !== 'password_page') {
return {
ready: true,
state: landingState,
@@ -145,6 +199,10 @@
};
}
async function ensureSignupPostEmailPageReadyInTab(tabId, step = 2, options = {}) {
return ensureSignupPostIdentityPageReadyInTab(tabId, step, options);
}
async function ensureSignupPasswordPageReadyInTab(tabId, step = 2, options = {}) {
const result = await ensureSignupPostEmailPageReadyInTab(tabId, step, options);
if (result.state !== 'password_page') {
@@ -200,8 +258,52 @@
return result || {};
}
async function resolveSignupEmailForFlow(state) {
function getPreservedPhoneIdentityForEmailResolution(state = {}, options = {}) {
if (!Boolean(options?.preserveAccountIdentity)) {
return null;
}
const accountIdentifierType = String(state?.accountIdentifierType || '').trim().toLowerCase();
const signupPhoneNumber = String(
state?.signupPhoneNumber
|| (accountIdentifierType === 'phone' ? state?.accountIdentifier : '')
|| state?.signupPhoneCompletedActivation?.phoneNumber
|| state?.signupPhoneActivation?.phoneNumber
|| ''
).trim();
if (accountIdentifierType !== 'phone' && !signupPhoneNumber) {
return null;
}
return {
accountIdentifierType: 'phone',
accountIdentifier: signupPhoneNumber || String(state?.accountIdentifier || '').trim(),
signupPhoneNumber,
signupPhoneActivation: state?.signupPhoneActivation || null,
signupPhoneCompletedActivation: state?.signupPhoneCompletedActivation || null,
signupPhoneVerificationRequestedAt: state?.signupPhoneVerificationRequestedAt ?? null,
signupPhoneVerificationPurpose: state?.signupPhoneVerificationPurpose || '',
};
}
async function persistResolvedSignupEmail(resolvedEmail, state = {}, options = {}) {
if (resolvedEmail === state.email && !options?.preserveAccountIdentity) {
return;
}
const preservedPhoneIdentity = getPreservedPhoneIdentityForEmailResolution(state, options);
if (preservedPhoneIdentity && typeof setState === 'function') {
await setState({
email: resolvedEmail,
...preservedPhoneIdentity,
});
return;
}
if (resolvedEmail !== state.email) {
await setEmailState(resolvedEmail);
}
}
async function resolveSignupEmailForFlow(state, options = {}) {
let resolvedEmail = state.email;
let generatedEmailAlreadyPersisted = false;
if (isHotmailProvider(state)) {
const account = await ensureHotmailAccountForFlow({
allowAllocate: true,
@@ -225,14 +327,17 @@
if (!isReusableGeneratedAliasEmail?.(state, resolvedEmail)) {
resolvedEmail = buildGeneratedAliasEmail(state);
}
} else if (!resolvedEmail && typeof fetchGeneratedEmail === 'function') {
resolvedEmail = await fetchGeneratedEmail(state, options);
generatedEmailAlreadyPersisted = true;
}
if (!resolvedEmail) {
throw new Error('缺少邮箱地址,请先在侧边栏粘贴邮箱。');
}
if (resolvedEmail !== state.email) {
await setEmailState(resolvedEmail);
if (!generatedEmailAlreadyPersisted || options?.preserveAccountIdentity) {
await persistResolvedSignupEmail(resolvedEmail, state, options);
}
return resolvedEmail;
@@ -240,6 +345,7 @@
return {
ensureSignupEntryPageReady,
ensureSignupPostIdentityPageReadyInTab,
ensureSignupPostEmailPageReadyInTab,
finalizeSignupPasswordSubmitInTab,
ensureSignupPasswordPageReadyInTab,
+23 -12
View File
@@ -47,6 +47,10 @@
return visibleStep >= 12 ? 10 : 7;
}
function addStepLog(step, message, level = 'info') {
return addLog(message, level, { step, stepKey: 'confirm-oauth' });
}
async function executeStep9(state) {
const visibleStep = getVisibleStep(state, 9);
let activeState = state;
@@ -56,7 +60,7 @@
throw new Error(`缺少登录用 OAuth 链接,请先完成步骤 ${authLoginStep}`);
}
await addLog(`步骤 ${visibleStep}正在监听 localhost 回调地址...`);
await addStepLog(visibleStep, '正在监听 localhost 回调地址...');
let callbackTimeoutMs = LOCALHOST_CALLBACK_LOCAL_TIMEOUT_MS;
let timeoutRecoveryAttempted = false;
@@ -115,7 +119,7 @@
resolved = true;
cleanupListener();
addLog(`步骤 ${visibleStep}已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => {
addStepLog(visibleStep, `已捕获 localhost 地址:${callbackUrl}`, 'ok').then(() => {
return completeStepFromBackground(visibleStep, { localhostUrl: callbackUrl });
}).then(() => {
resolve();
@@ -185,10 +189,10 @@
if (signupTabId && await isTabAlive('signup-page')) {
await chrome.tabs.update(signupTabId, { active: true });
await addLog(`步骤 ${visibleStep}已切回认证页,正在准备调试器点击...`);
await addStepLog(visibleStep, '已切回认证页,正在准备调试器点击...');
} else {
signupTabId = await reuseOrCreateTab('signup-page', activeState.oauthUrl);
await addLog(`步骤 ${visibleStep}已重新打开认证页,正在准备调试器点击...`);
await addStepLog(visibleStep, '已重新打开认证页,正在准备调试器点击...');
}
throwIfStep8SettledOrStopped(resolved);
@@ -202,7 +206,9 @@
actionLabel: '等待 OAuth 同意页内容脚本就绪',
})
: 15000,
logMessage: `步骤 ${visibleStep}:认证页内容脚本尚未就绪,正在等待页面恢复...`,
visibleStep,
logStepKey: 'confirm-oauth',
logMessage: '认证页内容脚本尚未就绪,正在等待页面恢复...',
});
for (let round = 1; round <= STEP8_MAX_ROUNDS && !resolved; round++) {
@@ -214,7 +220,8 @@
step: visibleStep,
actionLabel: '等待 OAuth 同意页出现',
})
: STEP8_READY_WAIT_TIMEOUT_MS
: STEP8_READY_WAIT_TIMEOUT_MS,
{ visibleStep }
);
if (!pageState?.consentReady) {
await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS);
@@ -223,7 +230,7 @@
const strategy = STEP8_STRATEGIES[Math.min(round - 1, STEP8_STRATEGIES.length - 1)];
await addLog(`步骤 ${visibleStep}${round}/${STEP8_MAX_ROUNDS} 轮尝试点击“继续”(${strategy.label}...`);
await addStepLog(visibleStep, `${round}/${STEP8_MAX_ROUNDS} 轮尝试点击“继续”(${strategy.label}...`);
if (strategy.mode === 'debugger') {
const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
@@ -235,9 +242,10 @@
const clickTarget = await prepareStep8DebuggerClick(signupTabId, {
timeoutMs: clickActionTimeoutMs,
responseTimeoutMs: clickActionTimeoutMs,
visibleStep,
});
throwIfStep8SettledOrStopped(resolved);
await clickWithDebugger(signupTabId, clickTarget?.rect);
await clickWithDebugger(signupTabId, clickTarget?.rect, { visibleStep });
} else {
const clickActionTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(15000, {
@@ -248,6 +256,7 @@
await triggerStep8ContentStrategy(signupTabId, strategy.strategy, {
timeoutMs: clickActionTimeoutMs,
responseTimeoutMs: clickActionTimeoutMs,
visibleStep,
});
}
@@ -263,14 +272,15 @@
step: visibleStep,
actionLabel: '等待 OAuth 同意页点击生效',
})
: 15000
: 15000,
{ visibleStep }
);
if (resolved) {
return;
}
if (effect.progressed) {
await addLog(`步骤 ${visibleStep}检测到本次点击已生效,${getStep8EffectLabel(effect)},继续等待 localhost 回调...`, 'info');
await addStepLog(visibleStep, `检测到本次点击已生效,${getStep8EffectLabel(effect)},继续等待 localhost 回调...`, 'info');
break;
}
@@ -278,7 +288,7 @@
throw new Error(`步骤 ${visibleStep}:连续 ${STEP8_MAX_ROUNDS} 轮点击“继续”后页面仍无反应。`);
}
await addLog(`步骤 ${visibleStep}${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS}...`, 'warn');
await addStepLog(visibleStep, `${strategy.label} 本轮点击后页面无反应,正在刷新认证页后重试(下一轮 ${round + 1}/${STEP8_MAX_ROUNDS}...`, 'warn');
await reloadStep8ConsentPage(
signupTabId,
typeof getOAuthFlowStepTimeoutMs === 'function'
@@ -286,7 +296,8 @@
step: visibleStep,
actionLabel: '刷新 OAuth 同意页',
})
: 30000
: 30000,
{ visibleStep }
);
await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS);
}
+368 -8
View File
@@ -4,6 +4,10 @@
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
const PLUS_CHECKOUT_ENTRY_URL = 'https://chatgpt.com/';
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js'];
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
const DEFAULT_GPC_HELPER_API_URL = 'https://gopay.hwork.pro';
function createPlusCheckoutCreateExecutor(deps = {}) {
const {
@@ -11,21 +15,42 @@
chrome,
completeStepFromBackground,
ensureContentScriptReadyOnTabUntilStopped,
fetch: fetchImpl = null,
markCurrentRegistrationAccountUsed = null,
registerTab,
sendTabMessageUntilStopped,
setState,
sleepWithStop,
waitForTabCompleteUntilStopped,
throwIfStopped = () => {},
} = deps;
function normalizePlusPaymentMethod(value = '') {
return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal';
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) {
return rootScope.GoPayUtils.normalizePlusPaymentMethod(value);
}
const normalized = String(value || '').trim().toLowerCase();
if (normalized === PLUS_PAYMENT_METHOD_GPC_HELPER) {
return PLUS_PAYMENT_METHOD_GPC_HELPER;
}
return normalized === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_PAYMENT_METHOD_GOPAY : PLUS_PAYMENT_METHOD_PAYPAL;
}
function getCheckoutModeLabel(state = {}) {
return normalizePlusPaymentMethod(state?.plusPaymentMethod) === 'gopay'
? 'GoPay 订阅页'
: 'Plus Checkout';
const paymentMethod = normalizePlusPaymentMethod(state?.plusPaymentMethod);
if (paymentMethod === PLUS_PAYMENT_METHOD_GPC_HELPER) {
return 'GPC 订阅页';
}
return paymentMethod === PLUS_PAYMENT_METHOD_GOPAY ? 'GoPay 订阅页' : 'Plus Checkout';
}
function getPlusPaymentMethodLabel(method = PLUS_PAYMENT_METHOD_PAYPAL) {
const paymentMethod = normalizePlusPaymentMethod(method);
if (paymentMethod === PLUS_PAYMENT_METHOD_GPC_HELPER) {
return 'GPC';
}
return paymentMethod === PLUS_PAYMENT_METHOD_GOPAY ? 'GoPay' : 'PayPal';
}
async function openFreshChatGptTabForCheckoutCreate() {
@@ -40,7 +65,343 @@
return tabId;
}
function normalizeHelperCountryCode(countryCode = '86') {
const digits = String(countryCode || '').replace(/\D/g, '');
return digits || '86';
}
function normalizeHelperPhoneNumber(phone = '', countryCode = '86') {
const cleaned = String(phone || '').replace(/\D/g, '');
const countryDigits = normalizeHelperCountryCode(countryCode);
if (countryDigits && cleaned.startsWith(countryDigits) && cleaned.length > countryDigits.length) {
return cleaned.slice(countryDigits.length);
}
return cleaned;
}
function resolveGpcHelperCardKey(state = {}) {
const cardKey = String(state?.gopayHelperCardKey || state?.gpcCardKey || state?.cardKey || '').trim();
if (!cardKey) {
throw new Error('创建 GPC 订单失败:缺少卡密。');
}
return cardKey;
}
function resolveGpcHelperCustomerEmail(state = {}) {
const email = String(
state?.email
|| state?.currentEmail
|| state?.registrationEmail
|| state?.accountEmail
|| state?.mailboxEmail
|| ''
).trim().toLowerCase();
if (!email) {
throw new Error('创建 GPC 订单失败:缺少当前轮邮箱。');
}
return email;
}
function parseGpcAmount(value) {
if (typeof value === 'number') {
return Number.isFinite(value) ? { amount: value, raw: String(value) } : null;
}
if (typeof value !== 'string') {
return null;
}
const raw = String(value || '').trim();
if (!raw || !/\d/.test(raw)) {
return null;
}
const match = raw.match(/([+-]?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})|[+-]?\d+(?:[.,]\d{1,2})?)/);
if (!match) {
return null;
}
let numericText = String(match[1] || '').trim();
const lastComma = numericText.lastIndexOf(',');
const lastDot = numericText.lastIndexOf('.');
if (lastComma > -1 && lastDot > -1) {
const decimalSeparator = lastComma > lastDot ? ',' : '.';
const thousandsSeparator = decimalSeparator === ',' ? '.' : ',';
numericText = numericText
.replace(new RegExp(`\\${thousandsSeparator}`, 'g'), '')
.replace(decimalSeparator, '.');
} else if (lastComma > -1) {
numericText = numericText.replace(',', '.');
}
const amount = Number(numericText.replace(/[^\d.+-]/g, ''));
return Number.isFinite(amount) ? { amount, raw } : null;
}
function isGpcAmountKey(key = '') {
const normalized = String(key || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_');
if (!normalized) {
return false;
}
if (/(?:^|_)(?:id|guid|uuid|phone|country|postal|zip|code|count|status|time|timestamp|created|updated|expires|challenge|client|reference|currency|state)(?:_|$)/i.test(normalized)) {
return false;
}
return /(?:amount|balance|total|due|payable|gross|subtotal|price|charge)/i.test(normalized);
}
function findGpcNonZeroAmount(payload = {}) {
const seen = new Set();
function visit(value, path = [], depth = 0) {
if (value == null || depth > 10) {
return null;
}
const key = path[path.length - 1] || '';
if (isGpcAmountKey(key)) {
const parsed = parseGpcAmount(value);
if (parsed && Math.abs(parsed.amount) >= 0.005) {
return { ...parsed, path: path.join('.') };
}
}
if (typeof value !== 'object') {
return null;
}
if (seen.has(value)) {
return null;
}
seen.add(value);
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
const found = visit(value[index], [...path, String(index)], depth + 1);
if (found) return found;
}
return null;
}
for (const [childKey, childValue] of Object.entries(value)) {
const found = visit(childValue, [...path, childKey], depth + 1);
if (found) return found;
}
return null;
}
return visit(payload);
}
async function abortGpcNonFreeTrialIfNeeded(data = {}, state = {}) {
const nonZeroAmount = findGpcNonZeroAmount(data);
if (!nonZeroAmount) {
return;
}
const amountLabel = nonZeroAmount.raw || String(nonZeroAmount.amount);
await addLog(`步骤 6GPC 接口返回余额非 0${amountLabel}),当前账号没有免费试用资格,将跳过当前账号。`, 'warn');
if (typeof markCurrentRegistrationAccountUsed === 'function') {
await markCurrentRegistrationAccountUsed(state, {
reason: 'plus-checkout-non-free-trial',
logPrefix: 'GPC:当前账号没有免费试用资格',
});
}
throw new Error(`PLUS_CHECKOUT_NON_FREE_TRIAL::步骤 6GPC 接口返回余额非 0(${amountLabel}),当前账号没有免费试用资格,已跳过支付提交。`);
}
function normalizeGpcHelperBaseUrl(apiUrl = '') {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.normalizeGpcHelperBaseUrl) {
return rootScope.GoPayUtils.normalizeGpcHelperBaseUrl(apiUrl);
}
let normalized = String(apiUrl || DEFAULT_GPC_HELPER_API_URL).trim().replace(/\/+$/g, '');
normalized = normalized.replace(/\/api\/checkout\/start$/i, '');
normalized = normalized.replace(/\/api\/gopay\/(?:otp|pin)$/i, '');
normalized = normalized.replace(/\/api\/card\/balance(?:\?.*)?$/i, '');
return normalized || DEFAULT_GPC_HELPER_API_URL;
}
function buildGpcHelperApiUrl(apiUrl = '', path = '') {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.buildGpcHelperApiUrl) {
return rootScope.GoPayUtils.buildGpcHelperApiUrl(apiUrl, path);
}
const baseUrl = normalizeGpcHelperBaseUrl(apiUrl);
if (!baseUrl) {
return '';
}
const normalizedPath = String(path || '').startsWith('/') ? String(path || '') : `/${String(path || '')}`;
return `${baseUrl}${normalizedPath}`;
}
async function fetchJsonWithTimeout(url, options = {}, timeoutMs = 30000) {
const fetcher = typeof fetchImpl === 'function'
? fetchImpl
: (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
if (typeof fetcher !== 'function') {
throw new Error('当前运行环境不支持 fetch,无法调用 GPC API。');
}
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 30000)) : null;
try {
const response = await fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) });
const data = await response.json().catch(() => ({}));
return { response, data };
} finally {
if (timer) clearTimeout(timer);
}
}
async function readAccessTokenFromChatGptSessionTab(tabId) {
await waitForTabCompleteUntilStopped(tabId);
await sleepWithStop(1000);
await ensureContentScriptReadyOnTabUntilStopped(PLUS_CHECKOUT_SOURCE, tabId, {
inject: PLUS_CHECKOUT_INJECT_FILES,
injectSource: PLUS_CHECKOUT_SOURCE,
logMessage: '步骤 6:正在等待 ChatGPT 页面完成加载,再继续获取 accessToken...',
});
const sessionResult = await sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, {
type: 'PLUS_CHECKOUT_GET_STATE',
source: 'background',
payload: {
includeSession: true,
includeAccessToken: true,
},
});
if (sessionResult?.error) {
throw new Error(sessionResult.error);
}
return String(sessionResult?.accessToken || sessionResult?.session?.accessToken || '').trim();
}
async function generateGpcCheckoutFromApi(accessToken = '', state = {}) {
const token = String(accessToken || '').trim();
if (!token) {
throw new Error('创建 GPC 订单失败:缺少 accessToken。');
}
const apiUrl = buildGpcHelperApiUrl(state?.gopayHelperApiUrl, '/api/checkout/start');
if (!apiUrl) {
throw new Error('创建 GPC 订单失败:缺少 API 地址。');
}
const phoneNumber = String(state?.gopayHelperPhoneNumber || '').trim();
const countryCode = normalizeHelperCountryCode(state?.gopayHelperCountryCode || '86');
const pin = String(state?.gopayHelperPin || '').trim();
const cardKey = resolveGpcHelperCardKey(state);
if (!phoneNumber) {
throw new Error('创建 GPC 订单失败:缺少手机号。');
}
if (!pin) {
throw new Error('创建 GPC 订单失败:缺少 PIN。');
}
throwIfStopped();
const payload = {
token,
entry_point: 'all_plans_pricing_modal',
plan_name: 'chatgptplusplan',
billing_details: { country: 'ID', currency: 'IDR' },
promo_campaign: {
promo_campaign_id: 'plus-1-month-free',
is_coupon_from_query_param: false,
},
checkout_ui_mode: 'custom',
proxy: { type: 'direct', url: '' },
tax_region: {
country: 'US',
line1: '1208 Oakdale Street',
city: 'Jonesboro',
postal_code: '72401',
state: 'AR',
},
customer_email: resolveGpcHelperCustomerEmail(state),
card_key: cardKey,
gopay_link: {
type: 'gopay',
country_code: countryCode,
phone_number: normalizeHelperPhoneNumber(phoneNumber, countryCode),
},
};
const { response, data } = await fetchJsonWithTimeout(apiUrl, {
method: 'POST',
headers: {
Accept: '*/*',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
}, 30000);
const referenceId = String(data?.reference_id || data?.referenceId || '').trim();
const gopayGuid = String(data?.gopay_guid || data?.gopayGuid || '').trim();
const redirectUrl = String(data?.redirect_url || data?.redirectUrl || '').trim();
const nextAction = String(data?.next_action || data?.nextAction || '').trim();
const flowId = String(data?.flow_id || data?.flowId || '').trim();
const challengeId = String(data?.challenge_id || data?.challengeId || '').trim();
if (response?.ok) {
await abortGpcNonFreeTrialIfNeeded(data, state);
}
if (!response?.ok || !referenceId) {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
const detail = rootScope.GoPayUtils?.extractGpcResponseErrorDetail
? rootScope.GoPayUtils.extractGpcResponseErrorDetail(data, response?.status || 0)
: (data?.detail || data?.message || data?.error || `HTTP ${response?.status || 0}`);
throw new Error(`创建 GPC 订单失败:${detail}`);
}
return {
referenceId,
gopayGuid,
redirectUrl,
nextAction,
flowId,
challengeId,
responsePayload: data && typeof data === 'object' && !Array.isArray(data) ? data : null,
country: 'ID',
currency: 'IDR',
checkoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER,
};
}
async function executeGpcCheckoutCreate(state = {}) {
let accessToken = String(state?.contributionAccessToken || state?.accessToken || state?.chatgptAccessToken || '').trim();
if (!accessToken) {
await addLog('步骤 6:正在获取 accessToken...', 'info');
const tokenTabId = await openFreshChatGptTabForCheckoutCreate();
try {
accessToken = await readAccessTokenFromChatGptSessionTab(tokenTabId);
} finally {
if (chrome?.tabs?.remove && Number.isInteger(tokenTabId)) {
await chrome.tabs.remove(tokenTabId).catch(() => {});
}
}
}
if (!accessToken) {
throw new Error('步骤 6GPC 模式获取 accessToken 失败。');
}
await addLog('步骤 6:正在调用 GPC 接口创建订单...', 'info');
const result = await generateGpcCheckoutFromApi(accessToken, state);
await setState({
plusCheckoutTabId: null,
plusCheckoutUrl: '',
plusCheckoutCountry: result.country || 'ID',
plusCheckoutCurrency: result.currency || 'IDR',
plusCheckoutSource: result.checkoutSource,
gopayHelperReferenceId: result.referenceId,
gopayHelperGoPayGuid: result.gopayGuid,
gopayHelperRedirectUrl: result.redirectUrl,
gopayHelperNextAction: result.nextAction,
gopayHelperFlowId: result.flowId,
gopayHelperChallengeId: result.challengeId,
gopayHelperStartPayload: result.responsePayload,
});
await addLog('步骤 6:GPC 订单已创建,准备继续下一步。', 'info');
await completeStepFromBackground(6, {
plusCheckoutCountry: result.country || 'ID',
plusCheckoutCurrency: result.currency || 'IDR',
plusCheckoutSource: result.checkoutSource,
});
}
async function executePlusCheckoutCreate(state = {}) {
const paymentMethod = normalizePlusPaymentMethod(state?.plusPaymentMethod);
if (paymentMethod === PLUS_PAYMENT_METHOD_GPC_HELPER) {
await executeGpcCheckoutCreate(state);
return;
}
const paymentMethodLabel = getPlusPaymentMethodLabel(paymentMethod);
const checkoutModeLabel = getCheckoutModeLabel(state);
await addLog(`步骤 6:正在打开新的 ChatGPT 会话,准备创建${checkoutModeLabel}...`, 'info');
const tabId = await openFreshChatGptTabForCheckoutCreate();
@@ -56,9 +417,7 @@
const result = await sendTabMessageUntilStopped(tabId, PLUS_CHECKOUT_SOURCE, {
type: 'CREATE_PLUS_CHECKOUT',
source: 'background',
payload: {
paymentMethod: normalizePlusPaymentMethod(state?.plusPaymentMethod),
},
payload: { paymentMethod },
});
if (result?.error) {
@@ -83,9 +442,10 @@
plusCheckoutUrl: result.checkoutUrl,
plusCheckoutCountry: result.country || 'DE',
plusCheckoutCurrency: result.currency || 'EUR',
plusCheckoutSource: '',
});
await addLog(`步骤 6${checkoutModeLabel}已就绪`, 'info');
await addLog(`步骤 6Plus Checkout 页面已就绪(${paymentMethodLabel} / ${result.country || 'DE'} ${result.currency || 'EUR'}),准备继续下一步`, 'info');
await completeStepFromBackground(6, {
plusCheckoutCountry: result.country || 'DE',
+285 -28
View File
@@ -2,10 +2,12 @@
root.MultiPageBackgroundStep8 = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep8Module() {
const MAIL_2925_FILTER_LOOKBACK_MS = 10 * 60 * 1000;
const STEP8_ADD_EMAIL_URL = 'https://auth.openai.com/add-email';
const STEP8_CURRENT_STEP_RECOVERY_MAX_ATTEMPTS = 3;
function createStep8Executor(deps = {}) {
const {
addLog,
addLog: rawAddLog = async () => {},
chrome,
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
completeStepFromBackground,
@@ -22,9 +24,12 @@
isTabAlive,
isVerificationMailPollingError,
LUCKMAIL_PROVIDER,
resolveSignupEmailForFlow,
resolveVerificationStep,
rerunStep7ForStep8Recovery,
reuseOrCreateTab,
sendToContentScriptResilient,
phoneVerificationHelpers = null,
setState,
shouldUseCustomRegistrationEmail,
sleepWithStop,
@@ -32,6 +37,33 @@
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS,
throwIfStopped,
} = deps;
let activeFetchLoginCodeStep = null;
function normalizeLogStep(value) {
const step = Math.floor(Number(value) || 0);
return step > 0 ? step : null;
}
function normalizeStepLogMessage(message) {
return String(message || '')
.replace(/^步骤\s*\d+\s*[:]\s*/, '')
.replace(/^Step\s+\d+\s*[:]\s*/i, '')
.trim();
}
function addLog(message, level = 'info', options = {}) {
const normalizedOptions = options && typeof options === 'object' ? { ...options } : {};
const step = normalizeLogStep(normalizedOptions.step || normalizedOptions.visibleStep)
|| normalizeLogStep(activeFetchLoginCodeStep);
if (step) {
normalizedOptions.step = step;
if (!normalizedOptions.stepKey) {
normalizedOptions.stepKey = 'fetch-login-code';
}
}
delete normalizedOptions.visibleStep;
return rawAddLog(normalizeStepLogMessage(message), level, normalizedOptions);
}
function getVisibleStep(state, fallback = 8) {
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
@@ -70,6 +102,102 @@
return String(value || '').trim().toLowerCase();
}
async function getLoginAuthStateFromContent(visibleStep, options = {}) {
if (typeof sendToContentScriptResilient !== 'function') {
return {};
}
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 15000);
const result = await sendToContentScriptResilient(
'signup-page',
{
type: 'GET_LOGIN_AUTH_STATE',
source: 'background',
payload: {},
},
{
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 600,
logMessage: options.logMessage || `步骤 ${visibleStep}:认证页正在切换,等待页面重新就绪...`,
logStep: visibleStep,
logStepKey: 'fetch-login-code',
}
);
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function submitAddEmailIfNeeded(state, visibleStep, initialPageState = null) {
if (typeof resolveSignupEmailForFlow !== 'function' || typeof sendToContentScriptResilient !== 'function') {
return { state, pageState: initialPageState };
}
const pageState = initialPageState?.state
? initialPageState
: await getLoginAuthStateFromContent(visibleStep, {
timeoutMs: 15000,
logMessage: `步骤 ${visibleStep}:正在确认是否已进入添加邮箱页...`,
});
if (pageState?.state !== 'add_email_page') {
return { state, pageState };
}
const latestState = typeof getState === 'function' ? await getState() : state;
const resolvedEmail = await resolveSignupEmailForFlow(latestState, {
preserveAccountIdentity: true,
});
await addLog(`步骤 ${visibleStep}:检测到添加邮箱页,正在添加邮箱 ${resolvedEmail} 并进入邮箱验证码页...`);
const timeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(60000, {
step: visibleStep,
actionLabel: '添加邮箱并进入验证码页',
oauthUrl: latestState?.oauthUrl || state?.oauthUrl || '',
})
: 60000;
const result = await sendToContentScriptResilient(
'signup-page',
{
type: 'SUBMIT_ADD_EMAIL',
source: 'background',
payload: { email: resolvedEmail },
},
{
timeoutMs,
responseTimeoutMs: timeoutMs,
retryDelayMs: 700,
logMessage: `步骤 ${visibleStep}:添加邮箱页面正在切换,等待邮箱验证码页就绪...`,
logStep: visibleStep,
logStepKey: 'fetch-login-code',
}
);
if (result?.error) {
throw new Error(result.error);
}
const displayedEmail = normalizeStep8VerificationTargetEmail(result?.displayedEmail || resolvedEmail);
await setState({
email: resolvedEmail,
step8VerificationTargetEmail: displayedEmail,
});
return {
state: {
...latestState,
email: resolvedEmail,
step8VerificationTargetEmail: displayedEmail,
},
pageState: {
state: result?.directOAuthConsentPage ? 'oauth_consent_page' : 'verification_page',
displayedEmail,
url: result?.url || pageState?.url || '',
},
};
}
async function completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, options = {}) {
await setState({
step8VerificationTargetEmail: '',
@@ -94,12 +222,69 @@
return /add-phone|手机号页面|手机号验证页|phone[\s-_]verification|phone\s+number/i.test(message);
}
function isStep8EmailInUseError(error) {
const message = String(error?.message || error || '');
return /STEP8_EMAIL_IN_USE::|email_in_use|email\s+(?:address\s+)?already\s+exists|already\s+associated\s+with\s+this\s+email/i.test(message);
}
function isStep8MaxCheckAttemptsError(error) {
const message = String(error?.message || error || '');
return /AUTH_MAX_CHECK_ATTEMPTS::|max_check_attempts/i.test(message);
}
async function openStep8AddEmailPage(state, visibleStep, reasonLabel = '') {
const tabId = typeof getTabId === 'function' ? await getTabId('signup-page') : 0;
const url = STEP8_ADD_EMAIL_URL;
if (tabId && chrome?.tabs?.update) {
await chrome.tabs.update(tabId, { url, active: true });
} else if (typeof reuseOrCreateTab === 'function') {
await reuseOrCreateTab('signup-page', url);
} else {
throw new Error(`Step ${visibleStep}: cannot reopen add-email page for Step 8 recovery.`);
}
if (typeof sleepWithStop === 'function') {
await sleepWithStop(1000);
}
await addLog(
`步骤 ${visibleStep}:重新打开添加邮箱页面${reasonLabel ? `${reasonLabel}` : ''}`,
'warn'
);
return {
...(state || {}),
oauthUrl: state?.oauthUrl || url,
};
}
async function resetStep8AfterEmailInUse(state, visibleStep) {
const currentEmail = String(state?.email || '').trim();
await setState({
email: null,
step8VerificationTargetEmail: '',
loginVerificationRequestedAt: null,
});
if (currentEmail) {
await addLog(`步骤 ${visibleStep}:检测到邮箱 ${currentEmail} 已被占用,已清理运行态并准备重新获取新邮箱。`, 'warn');
} else {
await addLog(`步骤 ${visibleStep}:检测到邮箱已被占用,已清理运行态并准备重新获取新邮箱。`, 'warn');
}
}
async function resetStep8AfterMaxCheckAttempts(visibleStep) {
await setState({
step8VerificationTargetEmail: '',
loginVerificationRequestedAt: null,
});
await addLog(`步骤 ${visibleStep}:检测到 max_check_attempts,将重新开始当前添加邮箱步骤,不继续点击重试。`, 'warn');
}
async function recoverStep8PollingFailure(currentState, visibleStep) {
const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep);
try {
const pageState = await ensureStep8VerificationPageReady({
visibleStep,
authLoginStep,
allowPhoneVerificationPage: true,
allowAddEmailPage: true,
timeoutMs: await getStep8ReadyTimeoutMs(
'登录验证码轮询异常后复核认证页状态',
currentState?.oauthUrl || '',
@@ -110,9 +295,9 @@
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep, { fromRecovery: true });
return { outcome: 'completed' };
}
if (pageState?.state === 'verification_page') {
if (pageState?.state === 'verification_page' || pageState?.state === 'phone_verification_page' || pageState?.state === 'add_email_page') {
await addLog(
`步骤 ${visibleStep}:检测到邮箱轮询/页面通信异常,但认证页仍在验证码页,先在当前链路重试,不回到步骤 ${authLoginStep}`,
`步骤 ${visibleStep}:检测到邮箱轮询/页面通信异常,但认证页仍在当前登录后续页面,先在当前链路重试,不回到步骤 ${authLoginStep}`,
'warn'
);
return { outcome: 'retry_without_step7' };
@@ -179,21 +364,30 @@
return Math.max(0, Number(STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS) || 0);
}
async function executeLoginPhoneCodeStep(state, signupTabId, visibleStep) {
if (!Number.isInteger(signupTabId)) {
throw new Error(`步骤 ${visibleStep}:认证页面标签页已关闭,无法继续手机号登录验证码流程。`);
}
if (typeof phoneVerificationHelpers?.completeLoginPhoneVerificationFlow !== 'function') {
throw new Error(`步骤 ${visibleStep}:手机号登录验证码流程不可用,接码模块尚未初始化。`);
}
const result = await phoneVerificationHelpers.completeLoginPhoneVerificationFlow(signupTabId, {
state,
visibleStep,
});
await completeStepFromBackground(visibleStep, {
phoneVerification: true,
loginPhoneVerification: true,
code: result?.code || '',
});
return result || {};
}
async function runStep8Attempt(state, runtime = {}) {
const visibleStep = getVisibleStep(state, 8);
const mail = getMailConfig(state);
if (mail.error) throw new Error(mail.error);
const stateLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
let latestResendAt = Math.max(0, Number(runtime?.stickyLastResendAt) || 0, stateLastResendAt);
const notifyResendRequestedAt = typeof runtime?.onResendRequestedAt === 'function'
? runtime.onResendRequestedAt
: null;
const stepStartedAt = Date.now();
const verificationFilterAfterTimestamp = mail.provider === '2925'
? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS)
: stepStartedAt;
const verificationSessionKey = `8:${stepStartedAt}`;
activeFetchLoginCodeStep = visibleStep;
const authTabId = await getTabId('signup-page');
if (authTabId) {
@@ -205,22 +399,58 @@
await reuseOrCreateTab('signup-page', state.oauthUrl);
}
const stateLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
let latestResendAt = Math.max(0, Number(runtime?.stickyLastResendAt) || 0, stateLastResendAt);
const notifyResendRequestedAt = typeof runtime?.onResendRequestedAt === 'function'
? runtime.onResendRequestedAt
: null;
throwIfStopped();
const pageState = await ensureStep8VerificationPageReady({
let pageState = await ensureStep8VerificationPageReady({
visibleStep,
authLoginStep: getAuthLoginStepForVisibleStep(visibleStep),
allowPhoneVerificationPage: true,
allowAddEmailPage: true,
timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || '', visibleStep),
});
if (pageState?.state === 'oauth_consent_page') {
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep);
return;
}
if (pageState?.state === 'phone_verification_page') {
return executeLoginPhoneCodeStep(state, authTabId, visibleStep);
}
let preparedState = state;
const addEmailPreparation = await submitAddEmailIfNeeded(preparedState, visibleStep, pageState);
preparedState = addEmailPreparation?.state || preparedState;
pageState = addEmailPreparation?.pageState || pageState;
if (pageState?.state === 'oauth_consent_page') {
await completeStep8WhenAuthAlreadyOnOauthConsent(visibleStep);
return;
}
if (pageState?.state === 'phone_verification_page') {
return executeLoginPhoneCodeStep(preparedState, authTabId, visibleStep);
}
const preparedStateLastResendAt = Number(preparedState?.loginVerificationRequestedAt) || 0;
if (preparedStateLastResendAt > 0) {
latestResendAt = Math.max(latestResendAt, preparedStateLastResendAt);
}
const mail = getMailConfig(preparedState);
if (mail.error) throw new Error(mail.error);
const stepStartedAt = Date.now();
const verificationFilterAfterTimestamp = mail.provider === '2925'
? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS)
: stepStartedAt;
const verificationSessionKey = `8:${stepStartedAt}`;
const shouldCompareVerificationEmail = mail.provider !== '2925';
const displayedVerificationEmail = shouldCompareVerificationEmail
? normalizeStep8VerificationTargetEmail(pageState?.displayedEmail)
: '';
const fixedTargetEmail = shouldCompareVerificationEmail
? (displayedVerificationEmail || normalizeStep8VerificationTargetEmail(state?.email))
? (displayedVerificationEmail || normalizeStep8VerificationTargetEmail(preparedState?.email))
: '';
await setState({
@@ -232,7 +462,7 @@
await addLog(`步骤 ${visibleStep}:已固定当前验证码页显示邮箱 ${displayedVerificationEmail} 作为后续匹配目标。`, 'info');
}
if (shouldUseCustomRegistrationEmail(state)) {
if (shouldUseCustomRegistrationEmail(preparedState)) {
await confirmCustomVerificationStepBypass(8, {
completionStep: visibleStep,
promptStep: visibleStep,
@@ -243,7 +473,7 @@
if (mail.source === 'icloud-mail' && typeof ensureIcloudMailSession === 'function') {
await addLog(`步骤 ${visibleStep}:正在确认 iCloud 邮箱登录态...`, 'info');
await ensureIcloudMailSession({
state,
state: preparedState,
step: 8,
actionLabel: `步骤 ${visibleStep}:确认 iCloud 邮箱登录态`,
});
@@ -260,10 +490,10 @@
await addLog(`步骤 ${visibleStep}:正在打开${mail.label}...`);
if (mail.provider === '2925' && typeof ensureMail2925MailboxSession === 'function') {
await ensureMail2925MailboxSession({
accountId: state.currentMail2925AccountId || null,
accountId: preparedState.currentMail2925AccountId || null,
forceRelogin: false,
allowLoginWhenOnLoginPage: Boolean(state?.mail2925UseAccountPool),
expectedMailboxEmail: getExpectedMail2925MailboxEmail(state),
allowLoginWhenOnLoginPage: Boolean(preparedState?.mail2925UseAccountPool),
expectedMailboxEmail: getExpectedMail2925MailboxEmail(preparedState),
actionLabel: `Step ${visibleStep}: ensure 2925 mailbox session`,
});
} else {
@@ -275,14 +505,14 @@
}
await resolveVerificationStep(8, {
...state,
...preparedState,
step8VerificationTargetEmail: displayedVerificationEmail || '',
}, mail, {
completionStep: visibleStep,
filterAfterTimestamp: verificationFilterAfterTimestamp,
sessionKey: verificationSessionKey,
disableTimeBudgetCap: mail.provider === '2925',
getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || '', visibleStep),
getRemainingTimeMs: getStep8RemainingTimeResolver(preparedState?.oauthUrl || '', visibleStep),
requestFreshCodeFirst: false,
lastResendAt: latestResendAt,
onResendRequestedAt: async (requestedAt) => {
@@ -318,6 +548,7 @@
let stickyLastResendAt = Number(state?.loginVerificationRequestedAt) || 0;
let retryWithoutStep7Streak = 0;
const maxRetryWithoutStep7Streak = 3;
let currentStepRecoveryAttempt = 0;
while (true) {
try {
@@ -340,6 +571,28 @@
const authLoginStep = getAuthLoginStepForVisibleStep(visibleStep);
let currentError = err;
let retryWithoutStep7 = false;
if (isStep8EmailInUseError(currentError) || isStep8MaxCheckAttemptsError(currentError)) {
currentStepRecoveryAttempt += 1;
if (currentStepRecoveryAttempt > STEP8_CURRENT_STEP_RECOVERY_MAX_ATTEMPTS) {
throw currentError;
}
if (isStep8EmailInUseError(currentError)) {
await resetStep8AfterEmailInUse(currentState, visibleStep);
await openStep8AddEmailPage(currentState, visibleStep, 'email_in_use');
} else {
await resetStep8AfterMaxCheckAttempts(visibleStep);
await openStep8AddEmailPage(currentState, visibleStep, 'max_check_attempts');
}
const latestState = typeof getState === 'function' ? await getState() : currentState;
currentState = {
...(currentState || {}),
...(latestState || {}),
oauthUrl: currentState?.oauthUrl || latestState?.oauthUrl || STEP8_ADD_EMAIL_URL,
};
continue;
}
const isMailPollingError = isVerificationMailPollingError(err);
if (isMailPollingError && !isStep8RestartStep7Error(err)) {
const recovery = await recoverStep8PollingFailure(currentState, visibleStep);
@@ -371,7 +624,9 @@
'warn'
);
await rerunStep7ForStep8Recovery({
logMessage: `步骤 ${visibleStep}邮箱通信异常持续未恢复,正在回到步骤 ${authLoginStep} 重新发起登录流程...`,
logMessage: `邮箱通信异常持续未恢复,正在回到步骤 ${authLoginStep} 重新发起登录流程...`,
logStep: visibleStep,
logStepKey: 'fetch-login-code',
});
currentState = await getState();
retryWithoutStep7Streak = 0;
@@ -415,8 +670,10 @@
);
await rerunStep7ForStep8Recovery({
logMessage: isStep8RestartStep7Error(currentError)
? `步骤 ${visibleStep}认证页进入重试/超时报错状态,正在回到步骤 ${authLoginStep} 重新发起登录流程...`
: `步骤 ${visibleStep}正在回到步骤 ${authLoginStep},重新发起登录验证码流程...`,
? `认证页进入重试/超时报错状态,正在回到步骤 ${authLoginStep} 重新发起登录流程...`
: `正在回到步骤 ${authLoginStep},重新发起登录验证码流程...`,
logStep: visibleStep,
logStepKey: 'fetch-login-code',
});
currentState = await getState();
}
+50 -6
View File
@@ -27,6 +27,9 @@
shouldUseCustomRegistrationEmail,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
throwIfStopped,
waitForTabStableComplete = null,
phoneVerificationHelpers = null,
resolveSignupMethod = () => 'email',
} = deps;
function buildSignupProfileForVerificationStep() {
@@ -58,6 +61,32 @@
return String(state?.mail2925BaseEmail || '').trim().toLowerCase();
}
function isPhoneSignupState(state = {}) {
return resolveSignupMethod(state) === 'phone'
|| state?.accountIdentifierType === 'phone'
|| Boolean(state?.signupPhoneActivation);
}
async function executeSignupPhoneCodeStep(state, signupTabId) {
if (typeof phoneVerificationHelpers?.completeSignupPhoneVerificationFlow !== 'function') {
throw new Error('步骤 4:手机号注册验证码流程不可用,接码模块尚未初始化。');
}
const signupProfile = buildSignupProfileForVerificationStep();
const result = await phoneVerificationHelpers.completeSignupPhoneVerificationFlow(signupTabId, {
state,
signupProfile,
});
await completeStepFromBackground(4, {
phoneVerification: true,
code: result?.code || '',
...(result?.skipProfileStep ? { skipProfileStep: true } : {}),
...(result?.skipProfileStepReason ? { skipProfileStepReason: result.skipProfileStepReason } : {}),
});
return result || {};
}
async function focusOrOpenMailTab(mail) {
const alive = await isTabAlive(mail.source);
if (alive) {
@@ -81,13 +110,7 @@
}
async function executeStep4(state) {
const mail = getMailConfig(state);
if (mail.error) throw new Error(mail.error);
const stepStartedAt = Date.now();
const verificationFilterAfterTimestamp = mail.provider === '2925'
? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS)
: stepStartedAt;
const verificationSessionKey = `4:${stepStartedAt}`;
const signupTabId = await getTabId('signup-page');
@@ -97,6 +120,16 @@
await chrome.tabs.update(signupTabId, { active: true });
throwIfStopped();
if (typeof waitForTabStableComplete === 'function') {
await addLog('步骤 4:等待注册验证码页面完成加载后再继续...', 'info');
await waitForTabStableComplete(signupTabId, {
timeoutMs: 45000,
retryDelayMs: 300,
stableMs: 800,
initialDelayMs: 300,
});
}
throwIfStopped();
await addLog('步骤 4:正在确认注册验证码页面是否就绪,必要时自动恢复密码页超时报错...');
const prepareRequest = {
@@ -175,11 +208,22 @@
return;
}
if (isPhoneSignupState(state)) {
return executeSignupPhoneCodeStep(state, signupTabId);
}
if (shouldUseCustomRegistrationEmail(state)) {
await confirmCustomVerificationStepBypass(4);
return;
}
const mail = getMailConfig(state);
if (mail.error) throw new Error(mail.error);
const verificationFilterAfterTimestamp = mail.provider === '2925'
? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS)
: stepStartedAt;
if (mail.source === 'icloud-mail' && typeof ensureIcloudMailSession === 'function') {
await addLog('步骤 4:正在确认 iCloud 邮箱登录态...', 'info');
await ensureIcloudMailSession({
+71 -6
View File
@@ -9,16 +9,66 @@
generatePassword,
getTabId,
isTabAlive,
resolveSignupMethod,
sendToContentScript,
setPasswordState,
setState,
SIGNUP_PAGE_INJECT_FILES,
} = deps;
function normalizeSignupMethod(value = '') {
return String(value || '').trim().toLowerCase() === 'phone'
? 'phone'
: 'email';
}
function getResolvedSignupMethodForStep3(state = {}) {
if (typeof resolveSignupMethod === 'function') {
return normalizeSignupMethod(resolveSignupMethod(state));
}
const frozenMethod = String(state?.resolvedSignupMethod || '').trim().toLowerCase();
if (frozenMethod === 'phone' || frozenMethod === 'email') {
return normalizeSignupMethod(frozenMethod);
}
return normalizeSignupMethod(state?.signupMethod);
}
function resolveStep3AccountIdentity(state = {}) {
const resolvedEmail = String(state?.email || '').trim();
const rawAccountIdentifierType = String(state?.accountIdentifierType || '').trim().toLowerCase();
const signupPhoneNumber = String(
state?.signupPhoneNumber
|| (rawAccountIdentifierType === 'phone' ? state?.accountIdentifier : '')
|| ''
).trim();
const explicitEmailIdentity = rawAccountIdentifierType === 'email' && resolvedEmail;
const shouldUsePhoneIdentity = !explicitEmailIdentity && (
rawAccountIdentifierType === 'phone'
|| Boolean(signupPhoneNumber)
|| getResolvedSignupMethodForStep3(state) === 'phone'
);
const accountIdentifierType = shouldUsePhoneIdentity
? 'phone'
: (resolvedEmail ? 'email' : 'email');
const accountIdentifier = accountIdentifierType === 'phone'
? signupPhoneNumber
: resolvedEmail;
return {
accountIdentifierType,
accountIdentifier,
email: resolvedEmail,
phoneNumber: signupPhoneNumber,
};
}
async function executeStep3(state) {
const resolvedEmail = state.email;
if (!resolvedEmail) {
throw new Error('缺少邮箱地址,请先完成步骤 2。');
const identity = resolveStep3AccountIdentity(state);
if (!identity.accountIdentifier) {
if (identity.accountIdentifierType === 'phone') {
throw new Error('缺少注册手机号,请先完成步骤 2 或在侧栏填写注册手机号后再执行步骤 3。');
}
throw new Error('缺少注册账号,请先完成步骤 2。');
}
const signupTabId = await getTabId('signup-page');
@@ -30,7 +80,13 @@
await setPasswordState(password);
const accounts = state.accounts || [];
accounts.push({ email: resolvedEmail, createdAt: new Date().toISOString() });
accounts.push({
email: identity.email,
phoneNumber: identity.phoneNumber,
accountIdentifierType: identity.accountIdentifierType,
accountIdentifier: identity.accountIdentifier,
createdAt: new Date().toISOString(),
});
await setState({ accounts });
await chrome.tabs.update(signupTabId, { active: true });
@@ -42,14 +98,23 @@
logMessage: '步骤 3:密码页内容脚本未就绪,正在等待页面恢复...',
});
const identityLabel = identity.accountIdentifierType === 'phone'
? `注册手机号为 ${identity.accountIdentifier}`
: `邮箱为 ${identity.accountIdentifier}`;
await addLog(
`步骤 3:正在填写密码,邮箱为 ${resolvedEmail},密码为${state.customPassword ? '自定义' : '自动生成'}${password.length} 位)`
`步骤 3:正在填写密码,${identityLabel},密码为${state.customPassword ? '自定义' : '自动生成'}${password.length} 位)`
);
await sendToContentScript('signup-page', {
type: 'EXECUTE_STEP',
step: 3,
source: 'background',
payload: { email: resolvedEmail, password },
payload: {
email: identity.email,
phoneNumber: identity.phoneNumber,
accountIdentifierType: identity.accountIdentifierType,
accountIdentifier: identity.accountIdentifier,
password,
},
});
}
+581 -32
View File
@@ -5,6 +5,26 @@
const PLUS_CHECKOUT_INJECT_FILES = ['content/utils.js', 'content/plus-checkout.js'];
const PLUS_CHECKOUT_URL_PATTERN = /^https:\/\/chatgpt\.com\/checkout(?:\/|$)/i;
const PLUS_CHECKOUT_FRAME_READY_DELAY_MS = 500;
const PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS = 3;
const PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS = 20000;
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
const DEFAULT_GPC_HELPER_API_URL = 'https://gopay.hwork.pro';
const PAYMENT_METHOD_CONFIGS = {
[PLUS_PAYMENT_METHOD_PAYPAL]: {
id: PLUS_PAYMENT_METHOD_PAYPAL,
label: 'PayPal',
selectMessageType: 'PLUS_CHECKOUT_SELECT_PAYPAL',
redirectPattern: /paypal\./i,
},
[PLUS_PAYMENT_METHOD_GOPAY]: {
id: PLUS_PAYMENT_METHOD_GOPAY,
label: 'GoPay',
selectMessageType: 'PLUS_CHECKOUT_SELECT_GOPAY',
redirectPattern: /gopay|gojek|midtrans|xendit|stripe|checkout/i,
},
};
const MEIGUODIZHI_ADDRESS_ENDPOINT = 'https://www.meiguodizhi.com/api/v1/dz';
const MEIGUODIZHI_COUNTRY_CONFIG = {
AR: { path: '/ar-address', city: 'Buenos Aires', aliases: ['ar', 'argentina', '阿根廷'] },
@@ -16,6 +36,7 @@
FR: { path: '/fr-address', city: 'Paris', aliases: ['fr', 'fra', 'france', '法国'] },
GB: { path: '/uk-address', city: 'London', aliases: ['gb', 'uk', 'united kingdom', 'britain', 'england', '英国'] },
HK: { path: '/hk-address', city: 'Hong Kong', aliases: ['hk', 'hong kong', '香港'] },
ID: { path: '/id-address', city: 'Jakarta', aliases: ['id', 'indonesia', '印度尼西亚', '印尼'] },
IT: { path: '/it-address', city: 'Rome', aliases: ['it', 'ita', 'italy', '意大利'] },
JP: { path: '/jp-address', city: 'Tokyo', aliases: ['jp', 'jpn', 'japan', '日本', '日本国'] },
KR: { path: '/kr-address', city: 'Seoul', aliases: ['kr', 'kor', 'korea', 'south korea', '韩国'] },
@@ -34,18 +55,22 @@
function createPlusCheckoutBillingExecutor(deps = {}) {
const {
addLog,
broadcastDataUpdate,
chrome,
completeStepFromBackground,
ensureContentScriptReadyOnTabUntilStopped,
fetch: fetchImpl = null,
generateRandomName,
getAddressSeedForCountry,
getState,
getTabId,
isTabAlive,
markCurrentRegistrationAccountUsed,
setState,
sleepWithStop,
waitForTabCompleteUntilStopped,
waitForTabUrlMatchUntilStopped,
probeIpProxyExit = null,
throwIfStopped = () => {},
} = deps;
function isPlusCheckoutUrl(url = '') {
@@ -56,10 +81,307 @@
return String(value || '').replace(/\s+/g, ' ').trim();
}
function isGpcHelperCheckout(state = {}) {
return normalizePlusPaymentMethod(state?.plusPaymentMethod) === PLUS_PAYMENT_METHOD_GPC_HELPER
|| (normalizeText(state?.plusCheckoutSource) === PLUS_PAYMENT_METHOD_GPC_HELPER
&& Boolean(state?.gopayHelperReferenceId));
}
function compactCountryText(value = '') {
return normalizeText(value).toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff]/g, '');
}
function normalizePlusPaymentMethod(value = '') {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.normalizePlusPaymentMethod) {
return rootScope.GoPayUtils.normalizePlusPaymentMethod(value);
}
const normalized = String(value || '').trim().toLowerCase();
if (normalized === PLUS_PAYMENT_METHOD_GPC_HELPER) {
return PLUS_PAYMENT_METHOD_GPC_HELPER;
}
return normalized === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_PAYMENT_METHOD_GOPAY : PLUS_PAYMENT_METHOD_PAYPAL;
}
function getPaymentMethodConfig(method = PLUS_PAYMENT_METHOD_PAYPAL) {
return PAYMENT_METHOD_CONFIGS[normalizePlusPaymentMethod(method)] || PAYMENT_METHOD_CONFIGS[PLUS_PAYMENT_METHOD_PAYPAL];
}
function normalizeGpcHelperBaseUrl(apiUrl = '') {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.normalizeGpcHelperBaseUrl) {
return rootScope.GoPayUtils.normalizeGpcHelperBaseUrl(apiUrl);
}
let normalized = String(apiUrl || DEFAULT_GPC_HELPER_API_URL).trim().replace(/\/+$/g, '');
normalized = normalized.replace(/\/api\/checkout\/start$/i, '');
normalized = normalized.replace(/\/api\/gopay\/(?:otp|pin)$/i, '');
normalized = normalized.replace(/\/api\/card\/balance(?:\?.*)?$/i, '');
return normalized || DEFAULT_GPC_HELPER_API_URL;
}
async function fetchJsonWithTimeout(url, options = {}, timeoutMs = 30000) {
const fetcher = typeof fetchImpl === 'function'
? fetchImpl
: (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
if (typeof fetcher !== 'function') {
throw new Error('当前运行环境不支持 fetch,无法调用 GPC API。');
}
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timer = controller ? setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 30000)) : null;
try {
const response = await fetcher(url, { ...options, ...(controller ? { signal: controller.signal } : {}) });
const data = await response.json().catch(() => ({}));
return { response, data };
} finally {
if (timer) clearTimeout(timer);
}
}
function buildGpcOtpPayload(input = {}) {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.buildGpcOtpPayload) {
return rootScope.GoPayUtils.buildGpcOtpPayload(input);
}
const payload = {
reference_id: String(input.reference_id ?? input.referenceId ?? '').trim(),
otp: String(input.otp ?? input.code ?? '').trim().replace(/[^\d]/g, ''),
card_key: String(input.card_key ?? input.cardKey ?? '').trim(),
};
const gopayGuid = String(input.gopay_guid ?? input.gopayGuid ?? '').trim();
const redirectUrl = String(input.redirect_url ?? input.redirectUrl ?? '').trim();
const flowId = String(input.flow_id ?? input.flowId ?? '').trim();
if (flowId) payload.flow_id = flowId;
if (gopayGuid) payload.gopay_guid = gopayGuid;
if (redirectUrl) payload.redirect_url = redirectUrl;
return payload;
}
function buildGpcOtpRetryPayload(input = {}) {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.buildGpcOtpRetryPayload) {
return rootScope.GoPayUtils.buildGpcOtpRetryPayload(input);
}
const basePayload = buildGpcOtpPayload(input);
return { ...basePayload, code: basePayload.otp };
}
function buildGpcPinPayload(input = {}) {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.buildGpcPinPayload) {
return rootScope.GoPayUtils.buildGpcPinPayload(input);
}
const payload = {
reference_id: String(input.reference_id ?? input.referenceId ?? '').trim(),
challenge_id: String(input.challenge_id ?? input.challengeId ?? '').trim(),
gopay_guid: String(input.gopay_guid ?? input.gopayGuid ?? '').trim(),
pin: String(input.pin ?? '').trim().replace(/[^\d]/g, ''),
card_key: String(input.card_key ?? input.cardKey ?? '').trim(),
};
const redirectUrl = String(input.redirect_url ?? input.redirectUrl ?? '').trim();
const flowId = String(input.flow_id ?? input.flowId ?? '').trim();
if (flowId) payload.flow_id = flowId;
if (redirectUrl) payload.redirect_url = redirectUrl;
return payload;
}
function buildGpcPinRetryPayload(input = {}) {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.buildGpcPinRetryPayload) {
return rootScope.GoPayUtils.buildGpcPinRetryPayload(input);
}
const basePayload = buildGpcPinPayload(input);
return { ...basePayload, challengeId: basePayload.challenge_id };
}
function getGpcResponseErrorDetail(payload = {}, status = 0) {
const rootScope = typeof self !== 'undefined' ? self : globalThis;
if (rootScope.GoPayUtils?.extractGpcResponseErrorDetail) {
return rootScope.GoPayUtils.extractGpcResponseErrorDetail(payload, status);
}
if (payload && typeof payload === 'object') {
return payload.detail || payload.message || payload.error || payload.error_description || payload.reason || `HTTP ${status || 0}`;
}
return `HTTP ${status || 0}`;
}
async function postGpcJsonWithFallback(apiUrl, endpointPath, primaryPayload, fallbackPayload, timeoutMs = 30000) {
const requestUrl = `${apiUrl}${endpointPath}`;
const send = (payload) => fetchJsonWithTimeout(requestUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}, timeoutMs);
const firstResponse = await send(primaryPayload);
if (firstResponse?.response?.ok || !fallbackPayload) {
return { ...firstResponse, retried: false, payload: primaryPayload };
}
const status = Number(firstResponse?.response?.status || 0);
if (status !== 400 && status !== 422) {
return { ...firstResponse, retried: false, payload: primaryPayload };
}
const firstDetail = getGpcResponseErrorDetail(firstResponse?.data, status);
await addLog(`步骤 7GPC 接口返回 ${status}${firstDetail}),使用兼容字段重试。`, 'warn');
const secondResponse = await send(fallbackPayload);
return {
...secondResponse,
retried: true,
payload: fallbackPayload,
firstError: firstDetail,
firstStatus: status,
};
}
function getStateInternal() {
if (typeof getState === 'function') {
return getState();
}
return Promise.resolve({});
}
async function requestGpcOtpInput({ title = '', message = '', referenceId = '' }) {
const requestId = `otp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const payload = {
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: requestId,
plusManualConfirmationStep: 7,
plusManualConfirmationMethod: 'gopay-otp',
plusManualConfirmationTitle: title || 'GPC OTP 验证',
plusManualConfirmationMessage: message || '请输入 OTP 验证码',
gopayHelperOtpRequestId: requestId,
gopayHelperOtpReferenceId: referenceId,
gopayHelperResolvedOtp: '',
};
await setState(payload);
if (typeof broadcastDataUpdate === 'function') {
broadcastDataUpdate(payload);
}
return new Promise((resolve, reject) => {
const checkInterval = setInterval(async () => {
try {
throwIfStopped();
const currentState = await getStateInternal();
if (!currentState?.plusManualConfirmationPending || currentState?.plusManualConfirmationRequestId !== requestId) {
clearInterval(checkInterval);
const resolvedOtp = String(currentState?.gopayHelperResolvedOtp || '').trim().replace(/[^\d]/g, '');
if (resolvedOtp) {
resolve(resolvedOtp);
} else {
reject(new Error('OTP 输入已取消'));
}
}
} catch (error) {
clearInterval(checkInterval);
reject(error);
}
}, 500);
});
}
async function executeGpcHelperBilling(state = {}) {
const referenceId = String(state?.gopayHelperReferenceId || '').trim();
const apiUrl = normalizeGpcHelperBaseUrl(state?.gopayHelperApiUrl || '');
const cardKey = String(state?.gopayHelperCardKey || state?.gpcCardKey || state?.cardKey || '').trim();
if (!referenceId) {
throw new Error('步骤 7GPC 模式缺少 reference_id,请先执行步骤 6。');
}
if (!apiUrl) {
throw new Error('步骤 7GPC 模式缺少 API 地址。');
}
if (!cardKey) {
throw new Error('步骤 7GPC 模式缺少卡密。');
}
await addLog(`步骤 7:GPC 模式开始 OTP 验证(reference_id: ${referenceId}...`, 'info');
await addLog('步骤 7:等待用户输入 OTP...', 'info');
const otp = await requestGpcOtpInput({
title: 'GPC OTP 验证',
message: `请输入收到的 OTP 验证码(reference_id: ${referenceId}`,
referenceId,
});
const flowId = state?.gopayHelperFlowId
|| state?.gopayHelperStartPayload?.flow_id
|| state?.gopayHelperStartPayload?.flowId
|| state?.gopayHelperBalancePayload?.flow_id
|| state?.gopayHelperBalancePayload?.flowId
|| '';
const baseInput = {
reference_id: referenceId,
otp,
card_key: cardKey,
gopay_guid: state?.gopayHelperGoPayGuid || '',
redirect_url: state?.gopayHelperRedirectUrl || '',
flow_id: flowId,
};
await addLog('步骤 7:正在提交 OTP...', 'info');
const otpResponse = await postGpcJsonWithFallback(
apiUrl,
'/api/gopay/otp',
buildGpcOtpPayload(baseInput),
buildGpcOtpRetryPayload(baseInput),
30000
);
if (!otpResponse?.response?.ok) {
throw new Error(`步骤 7OTP 验证失败:${getGpcResponseErrorDetail(otpResponse?.data, otpResponse?.response?.status || 0)}`);
}
const otpData = otpResponse?.data || {};
const challengeId = String(
otpData?.challenge_id
|| otpData?.challengeId
|| state?.gopayHelperChallengeId
|| state?.gopayHelperStartPayload?.challenge_id
|| state?.gopayHelperStartPayload?.challengeId
|| ''
).trim();
const nextFlowId = String(otpData?.flow_id || otpData?.flowId || baseInput.flow_id || '').trim();
const gopayGuid = String(otpData?.gopay_guid || otpData?.gopayGuid || state?.gopayHelperGoPayGuid || '').trim();
const redirectUrl = String(otpData?.redirect_url || otpData?.redirectUrl || state?.gopayHelperRedirectUrl || '').trim();
if (!challengeId) {
throw new Error('步骤 7GPC OTP 验证后未返回 challenge_id。');
}
const pin = String(state?.gopayHelperPin || '').trim().replace(/[^\d]/g, '');
if (!pin) {
throw new Error('步骤 7GPC 模式缺少 PIN 配置。');
}
await setState({
gopayHelperChallengeId: challengeId,
gopayHelperFlowId: nextFlowId,
gopayHelperGoPayGuid: gopayGuid,
gopayHelperRedirectUrl: redirectUrl,
});
await addLog('步骤 7:正在提交 PIN...', 'info');
const pinInput = {
reference_id: referenceId,
challenge_id: challengeId,
gopay_guid: gopayGuid,
redirect_url: redirectUrl,
flow_id: nextFlowId,
pin,
card_key: cardKey,
};
const pinResponse = await postGpcJsonWithFallback(
apiUrl,
'/api/gopay/pin',
buildGpcPinPayload(pinInput),
buildGpcPinRetryPayload(pinInput),
30000
);
if (!pinResponse?.response?.ok) {
throw new Error(`步骤 7PIN 验证失败:${getGpcResponseErrorDetail(pinResponse?.data, pinResponse?.response?.status || 0)}`);
}
await setState({
plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER,
gopayHelperPinPayload: pinResponse?.data || null,
});
await addLog('步骤 7:GPC 支付完成,准备继续下一步。', 'ok');
await completeStepFromBackground(7, {
plusCheckoutSource: PLUS_PAYMENT_METHOD_GPC_HELPER,
});
}
function resolveMeiguodizhiCountryCode(value = '') {
const normalized = normalizeText(value);
const upper = normalized.toUpperCase();
@@ -71,7 +393,7 @@
compact === code.toLowerCase()
|| (config.aliases || []).some((alias) => {
const compactAlias = compactCountryText(alias);
return compact === compactAlias || compact.includes(compactAlias);
return compact === compactAlias || (compactAlias.length >= 4 && compact.includes(compactAlias));
})
));
return match?.[0] || '';
@@ -86,11 +408,31 @@
);
}
function normalizePostalCodeForCountry(countryCode, rawPostalCode = '', fallbackPostalCode = '') {
const normalizedCountry = resolveMeiguodizhiCountryCode(countryCode) || normalizeText(countryCode).toUpperCase();
const postalCode = normalizeText(rawPostalCode);
const fallback = normalizeText(fallbackPostalCode);
if (normalizedCountry !== 'KR') {
return postalCode;
}
if (/^\d{5}$/.test(postalCode)) {
return postalCode;
}
if (/^\d{5}$/.test(fallback)) {
return fallback;
}
return '04524';
}
function buildDirectAddressSeed(countryCode, apiAddress, fallbackSeed) {
const address1 = normalizeText(apiAddress?.Trans_Address || apiAddress?.Address);
const city = normalizeText(apiAddress?.City);
const region = normalizeText(apiAddress?.State_Full || apiAddress?.State);
const postalCode = normalizeText(apiAddress?.Zip_Code);
const postalCode = normalizePostalCodeForCountry(
countryCode,
apiAddress?.Zip_Code,
fallbackSeed?.fallback?.postalCode
);
if (!address1 || !city || !postalCode) {
return null;
}
@@ -168,9 +510,47 @@
};
}
async function resolveBillingAddressSeed(state = {}, countryOverride = '') {
const requestedCountry = normalizeText(countryOverride || state.plusCheckoutCountry || 'DE');
const countryCode = resolveMeiguodizhiCountryCode(requestedCountry) || 'DE';
function resolveBillingAddressCountry(state = {}, countryOverride = '', paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
const normalizedPaymentMethod = normalizePlusPaymentMethod(paymentMethod || state?.plusPaymentMethod);
const checkoutCountry = resolveMeiguodizhiCountryCode(countryOverride);
const savedCheckoutCountry = resolveMeiguodizhiCountryCode(state.plusCheckoutCountry);
const exitCountry = resolveMeiguodizhiCountryCode(
state.ipProxyAppliedExitRegion
|| state.ipProxyExitRegion
|| ''
);
if (normalizedPaymentMethod === PLUS_PAYMENT_METHOD_GOPAY) {
const countryCode = exitCountry || checkoutCountry || savedCheckoutCountry || 'ID';
return {
countryCode,
requestedCountry: exitCountry
|| normalizeText(countryOverride)
|| normalizeText(state.plusCheckoutCountry)
|| 'ID',
source: exitCountry ? 'proxy_exit' : (checkoutCountry ? 'checkout_page' : (savedCheckoutCountry ? 'checkout_state' : 'gopay_fallback')),
};
}
const countryCode = checkoutCountry || savedCheckoutCountry || exitCountry || 'DE';
return {
countryCode,
requestedCountry: normalizeText(countryOverride)
|| normalizeText(state.plusCheckoutCountry)
|| exitCountry
|| 'DE',
source: checkoutCountry ? 'checkout_page' : (savedCheckoutCountry ? 'checkout_state' : (exitCountry ? 'proxy_exit' : 'paypal_fallback')),
};
}
async function resolveBillingAddressSeed(state = {}, countryOverride = '', options = {}) {
const paymentMethod = normalizePlusPaymentMethod(options.paymentMethod || state?.plusPaymentMethod);
const countryResolution = resolveBillingAddressCountry(state, countryOverride, paymentMethod);
const countryCode = countryResolution.countryCode;
const requestedCountry = countryResolution.requestedCountry;
if (paymentMethod === PLUS_PAYMENT_METHOD_GOPAY && countryResolution.source === 'proxy_exit') {
await addLog(`步骤 7:GoPay 账单地址将按当前代理出口地区 ${countryCode} 填写。`, 'info');
}
const localSeed = getLocalAddressSeed(countryCode);
const lookupSeed = localSeed || buildMeiguodizhiLookupSeed(countryCode);
if (!lookupSeed) {
@@ -294,6 +674,33 @@
});
}
async function waitForPaymentRedirectAfterSubmit(tabId, paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
const paymentConfig = getPaymentMethodConfig(paymentMethod);
const startedAt = Date.now();
while (Date.now() - startedAt < PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS) {
const tab = await chrome.tabs.get(tabId).catch(() => null);
if (!tab) {
throw new Error(`步骤 7:checkout 标签页已关闭,无法继续等待 ${paymentConfig.label} 跳转。`);
}
const url = String(tab.url || '');
if (paymentConfig.redirectPattern.test(url) && !isPlusCheckoutUrl(url)) {
await waitForTabCompleteUntilStopped(tabId);
await sleepWithStop(1000);
return true;
}
if (url && !isPlusCheckoutUrl(url)) {
await addLog(`步骤 7:点击订阅后页面跳转到非 ${paymentConfig.label} 识别地址:${url}`, 'warn');
return false;
}
await sleepWithStop(500);
}
return false;
}
async function waitForPayPalRedirectAfterSubmit(tabId) {
return waitForPaymentRedirectAfterSubmit(tabId, PLUS_PAYMENT_METHOD_PAYPAL);
}
async function inspectCheckoutFrame(tabId, frame) {
try {
const result = await sendFrameMessage(tabId, frame.frameId, {
@@ -329,6 +736,7 @@
.map((item) => {
const flags = [];
if (item.result?.hasPayPal) flags.push('paypal');
if (item.result?.hasGoPay) flags.push('gopay');
if (item.result?.billingFieldsVisible) flags.push('billing');
if (item.result?.hasSubscribeButton) flags.push('subscribe');
if (!flags.length && item.error) flags.push(item.error);
@@ -348,7 +756,13 @@
return inspections;
}
function pickPaymentFrame(inspections) {
function pickPaymentFrame(inspections, paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
const normalizedPaymentMethod = normalizePlusPaymentMethod(paymentMethod);
if (normalizedPaymentMethod === PLUS_PAYMENT_METHOD_GOPAY) {
return inspections.find((item) => item.result?.hasGoPay || item.result?.gopayCandidates?.length)
|| inspections.find((item) => isPaymentFrameUrl(item.frame.url))
|| null;
}
return inspections.find((item) => item.result?.hasPayPal || item.result?.paypalCandidates?.length)
|| inspections.find((item) => isPaymentFrameUrl(item.frame.url))
|| null;
@@ -366,6 +780,44 @@
|| null;
}
function findCheckoutAmountInspection(inspections = []) {
return inspections.find((item) => item.result?.checkoutAmountSummary?.hasTodayDue)
|| null;
}
async function inspectCheckoutAmountSummary(tabId) {
const frames = await getReadyCheckoutFrames(tabId);
const inspections = await inspectCheckoutFrames(tabId, frames);
const amountInspection = findCheckoutAmountInspection(inspections);
return amountInspection?.result?.checkoutAmountSummary || null;
}
async function ensureFreeTrialAmount(tabId, state = {}, options = {}) {
const phaseLabel = String(options.phaseLabel || '').trim() || '提交前';
const amountSummary = await inspectCheckoutAmountSummary(tabId);
if (!amountSummary?.hasTodayDue) {
await addLog(`步骤 7${phaseLabel}未能识别 checkout 的“今日应付金额”,为避免误判将继续执行。`, 'warn');
return;
}
if (amountSummary.isZero) {
await addLog(`步骤 7${phaseLabel}已确认今日应付金额为 ${amountSummary.rawAmount || '0'},继续执行。`, 'ok');
return;
}
const amountLabel = amountSummary.rawAmount || (
Number.isFinite(Number(amountSummary.amount)) ? String(amountSummary.amount) : '未知金额'
);
await addLog(`步骤 7${phaseLabel}检测到今日应付金额不是 0${amountLabel}),说明当前账号没有免费试用资格,将跳过支付提交。`, 'warn');
if (typeof markCurrentRegistrationAccountUsed === 'function') {
await markCurrentRegistrationAccountUsed(state, {
reason: 'plus-checkout-non-free-trial',
logPrefix: 'Plus Checkout:当前账号没有免费试用资格',
});
}
throw new Error(`PLUS_CHECKOUT_NON_FREE_TRIAL::步骤 7:今日应付金额不是 0(${amountLabel}),当前账号没有免费试用资格,已跳过支付提交。`);
}
async function getReadyCheckoutFrames(tabId) {
return ensurePlusCheckoutFramesReady(tabId, await getCheckoutFrames(tabId));
}
@@ -383,9 +835,9 @@
};
}
async function resolvePaymentFrame(tabId, frames) {
async function resolvePaymentFrame(tabId, frames, paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
const inspections = await inspectCheckoutFrames(tabId, frames);
const picked = pickPaymentFrame(inspections);
const picked = pickPaymentFrame(inspections, paymentMethod);
if (picked) {
return {
frameId: picked.frame.frameId,
@@ -457,6 +909,12 @@
}
async function executePlusCheckoutBilling(state = {}) {
if (isGpcHelperCheckout(state)) {
await executeGpcHelperBilling(state);
return;
}
const paymentMethod = normalizePlusPaymentMethod(state?.plusPaymentMethod);
const paymentConfig = getPaymentMethodConfig(paymentMethod);
const tabId = await getCheckoutTabId(state);
await addLog('步骤 7:正在等待 Plus Checkout 页面加载完成...', 'info');
await waitForTabCompleteUntilStopped(tabId);
@@ -468,27 +926,30 @@
logMessage: '步骤 7Checkout 页面仍在加载,等待账单填写脚本就绪...',
});
const readyFrames = await getReadyCheckoutFrames(tabId);
const paymentFrame = await resolvePaymentFrame(tabId, readyFrames);
await ensureFreeTrialAmount(tabId, state, {
phaseLabel: 'Checkout 页面加载后',
});
const paymentFrame = await resolvePaymentFrame(tabId, readyFrames, paymentMethod);
if (paymentFrame.frameId === null) {
const frameSummary = buildFrameSummary(paymentFrame.inspections);
throw new Error(`步骤 7:未在主页面或 iframe 中发现 PayPal DOM,无法自动切换付款方式。frame 摘要:${frameSummary}`);
throw new Error(`步骤 7:未在主页面或 iframe 中发现 ${paymentConfig.label} DOM,无法自动切换付款方式。frame 摘要:${frameSummary}`);
}
if (!paymentFrame.ready) {
throw new Error(`步骤 7:已定位到 PayPal 所在 iframeframeId=${paymentFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`);
throw new Error(`步骤 7:已定位到 ${paymentConfig.label} 所在 iframeframeId=${paymentFrame.frameId}),但账单脚本无法注入该 iframe。请提供该 iframe 的控制台结构或截图。`);
}
if (paymentFrame.frameId !== 0) {
await addLog(`步骤 7PayPal 位于 checkout iframeframeId=${paymentFrame.frameId}),将改为在该 frame 内操作。`, 'info');
await addLog(`步骤 7${paymentConfig.label} 位于 checkout iframeframeId=${paymentFrame.frameId}),将改为在该 frame 内操作。`, 'info');
}
const randomName = generateRandomName();
const fullName = [randomName.firstName, randomName.lastName].filter(Boolean).join(' ');
await addLog('步骤 7:正在切换 PayPal 付款方式...', 'info');
await addLog(`步骤 7:正在切换 ${paymentConfig.label} 付款方式...`, 'info');
const paymentResult = await sendFrameMessage(tabId, paymentFrame.frameId, {
type: 'PLUS_CHECKOUT_SELECT_PAYPAL',
type: paymentConfig.selectMessageType,
source: 'background',
payload: {},
payload: { paymentMethod },
});
if (paymentResult?.error) {
throw new Error(paymentResult.error);
@@ -502,7 +963,67 @@
await addLog(`步骤 7:账单地址位于 checkout iframeframeId=${billingFrame.frameId}),将改为在该 frame 内填写。`, 'info');
}
const addressSeed = await resolveBillingAddressSeed(state, billingFrame.countryText);
let billingState = state;
if (paymentMethod === PLUS_PAYMENT_METHOD_GOPAY && typeof probeIpProxyExit === 'function') {
const staleExitRegion = normalizeText(
state?.ipProxyAppliedExitRegion
|| state?.ipProxyExitRegion
|| ''
);
try {
await addLog('步骤 7:GoPay 账单地址准备按代理出口填写,正在重新检测当前出口地区...', 'info');
const probeResult = await probeIpProxyExit({
state,
timeoutMs: 12000,
authRebindRetry: true,
detectWhenDisabled: true,
});
const routing = probeResult?.proxyRouting || {};
const probedExitRegion = normalizeText(routing.exitRegion || '');
const probedExitIp = normalizeText(routing.exitIp || '');
const probedExitSource = normalizeText(routing.exitSource || '');
const probeEndpoint = normalizeText(routing.endpoint || routing.exitEndpoint || '');
const probeReason = normalizeText(routing.reason || '');
const probeError = normalizeText(routing.exitError || routing.error || '');
if (probedExitRegion) {
billingState = {
...(state || {}),
ipProxyAppliedExitRegion: probedExitRegion,
ipProxyExitRegion: probedExitRegion,
ipProxyAppliedExitIp: probedExitIp,
ipProxyAppliedExitSource: probedExitSource,
};
const sourceSuffix = probedExitSource ? `,来源 ${probedExitSource}` : '';
const endpointSuffix = probeEndpoint ? `,检测地址 ${probeEndpoint}` : '';
await addLog(`步骤 7:当前代理出口复测结果:${probedExitRegion}${probedExitIp ? ` / ${probedExitIp}` : ''}${sourceSuffix}${endpointSuffix}`, 'info');
} else {
billingState = {
...(state || {}),
ipProxyAppliedExitRegion: '',
ipProxyExitRegion: '',
ipProxyAppliedExitIp: probedExitIp,
ipProxyAppliedExitSource: probedExitSource,
};
await addLog(
`步骤 7:代理出口复测没有返回国家/地区代码,已清空旧出口地区${staleExitRegion ? ` ${staleExitRegion}` : ''},不会继续沿用旧地区。${probeReason ? `状态:${probeReason}` : ''}${probeError ? `诊断:${probeError}` : ''}`,
'warn'
);
}
} catch (error) {
billingState = {
...(state || {}),
ipProxyAppliedExitRegion: '',
ipProxyExitRegion: '',
};
await addLog(`步骤 7:代理出口复测失败,已清空旧出口地区${staleExitRegion ? ` ${staleExitRegion}` : ''},不会继续沿用旧地区:${error?.message || String(error || '未知错误')}`, 'warn');
}
}
if (paymentMethod === PLUS_PAYMENT_METHOD_GOPAY
&& typeof probeIpProxyExit === 'function'
&& !resolveMeiguodizhiCountryCode(billingState?.ipProxyAppliedExitRegion || billingState?.ipProxyExitRegion || '')) {
throw new Error('步骤 7:GoPay 账单地址需要当前代理出口国家/地区,但本次复测没有拿到国家码;已停止填写,避免误用旧的 KR/ID 地区。请先点 IP 代理“检测出口”,确认显示 JP 后再继续。');
}
const addressSeed = await resolveBillingAddressSeed(billingState, billingFrame.countryText, { paymentMethod });
if (!addressSeed) {
throw new Error('步骤 7:未找到可用的本地账单地址种子。');
}
@@ -535,8 +1056,9 @@
addressSeed,
},
});
if (suggestionResult?.error) {
throw new Error(suggestionResult.error);
const suggestionError = suggestionResult?.error || '';
if (suggestionError) {
await addLog(`步骤 7:Google 地址推荐不可用,将改用本地地址字段兜底:${suggestionError}`, 'warn');
}
const structuredResult = await sendFrameMessage(tabId, billingFrame.frameId, {
@@ -544,6 +1066,7 @@
source: 'background',
payload: {
addressSeed,
overwriteStructuredAddress: Boolean(suggestionError),
},
});
if (structuredResult?.error) {
@@ -552,7 +1075,7 @@
result = {
...structuredResult,
selectedAddressText: suggestionResult?.selectedAddressText || '',
selectedAddressText: suggestionError ? '' : (suggestionResult?.selectedAddressText || ''),
};
} else {
result = await sendFrameMessage(tabId, billingFrame.frameId, {
@@ -569,7 +1092,26 @@
}
}
await addLog('步骤 7:账单地址已填写完成,正在定位订阅按钮...', 'info');
await setState({
plusCheckoutTabId: tabId,
plusBillingCountryText: result?.countryText || '',
plusBillingAddress: result?.structuredAddress || null,
});
await ensureFreeTrialAmount(tabId, state, {
phaseLabel: '提交订阅前',
});
let redirectedToPayment = false;
let lastSubmitError = '';
for (let attempt = 1; attempt <= PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS; attempt += 1) {
await addLog(
attempt === 1
? '步骤 7:账单地址已填写完成,等待 3 秒让 checkout 完成校验...'
: `步骤 7:准备第 ${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS} 次重新提交账单地址...`,
attempt === 1 ? 'info' : 'warn'
);
await sleepWithStop(3000);
await addLog('步骤 7:正在定位订阅按钮...', 'info');
const subscribeFrame = await waitForSubscribeFrame(tabId, [
{ frameId: 0, url: '' },
{ frameId: paymentFrame.frameId, url: paymentFrame.frameUrl || '' },
@@ -578,22 +1120,29 @@
const subscribeResult = await sendFrameMessage(tabId, subscribeFrame.frameId, {
type: 'PLUS_CHECKOUT_CLICK_SUBSCRIBE',
source: 'background',
payload: {},
payload: {
beforeClickDelayMs: attempt === 1 ? 700 : 1200,
paymentMethod,
},
});
if (subscribeResult?.error) {
throw new Error(subscribeResult.error);
lastSubmitError = subscribeResult.error;
await addLog(`步骤 7:点击订阅失败(${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS}):${lastSubmitError}`, 'warn');
continue;
}
await setState({
plusCheckoutTabId: tabId,
plusBillingCountryText: result?.countryText || '',
plusBillingAddress: result?.structuredAddress || null,
});
await addLog(`步骤 7:账单地址已提交,正在等待跳转到 ${paymentConfig.label}${attempt}/${PLUS_CHECKOUT_SUBMIT_MAX_ATTEMPTS}...`, 'info');
redirectedToPayment = await waitForPaymentRedirectAfterSubmit(tabId, paymentMethod);
if (redirectedToPayment) {
break;
}
lastSubmitError = `提交后 ${Math.round(PLUS_CHECKOUT_PAYPAL_REDIRECT_TIMEOUT_MS / 1000)} 秒内未跳转到 ${paymentConfig.label}`;
await addLog(`步骤 7${lastSubmitError},将重试提交。`, 'warn');
}
await addLog('步骤 7:账单地址已提交,正在等待跳转到 PayPal...', 'info');
await waitForTabUrlMatchUntilStopped(tabId, (url) => /paypal\./i.test(url));
await waitForTabCompleteUntilStopped(tabId);
await sleepWithStop(1000);
if (!redirectedToPayment) {
throw new Error(`步骤 7:多次提交账单地址后仍未跳转到 ${paymentConfig.label}${lastSubmitError}`);
}
await completeStepFromBackground(7, {
plusBillingCountryText: result?.countryText || '',
File diff suppressed because it is too large Load Diff
+81 -17
View File
@@ -41,12 +41,30 @@
}
async function executeStep7(state) {
if (!state.email) {
throw new Error('缺少邮箱地址,请先完成步骤 3。');
}
const visibleStep = Math.floor(Number(state?.visibleStep) || 0);
const completionStep = visibleStep > 0 ? visibleStep : 7;
const resolvedIdentifierType = String(
state?.accountIdentifierType
|| (state?.signupPhoneNumber ? 'phone' : '')
|| ''
).trim().toLowerCase() === 'phone'
? 'phone'
: 'email';
const phoneNumber = String(
state?.signupPhoneNumber
|| (resolvedIdentifierType === 'phone' ? state?.accountIdentifier : '')
|| state?.signupPhoneCompletedActivation?.phoneNumber
|| state?.signupPhoneActivation?.phoneNumber
|| ''
).trim();
const email = String(
state?.email
|| (resolvedIdentifierType === 'email' ? state?.accountIdentifier : '')
|| ''
).trim();
if (!email && !phoneNumber) {
throw new Error('缺少登录账号:请先完成步骤 2,或在侧栏“注册邮箱/注册手机号”中手动填写账号后再执行当前步骤。');
}
let attempt = 0;
let lastError = null;
@@ -57,25 +75,53 @@
try {
const currentState = attempt === 1 ? state : await getState();
const password = currentState.password || currentState.customPassword || '';
const currentIdentifierType = String(
currentState?.accountIdentifierType
|| (currentState?.signupPhoneNumber ? 'phone' : '')
|| resolvedIdentifierType
).trim().toLowerCase() === 'phone'
? 'phone'
: 'email';
const currentPhoneNumber = String(
currentState?.signupPhoneNumber
|| (currentIdentifierType === 'phone' ? currentState?.accountIdentifier : '')
|| currentState?.signupPhoneCompletedActivation?.phoneNumber
|| currentState?.signupPhoneActivation?.phoneNumber
|| phoneNumber
).trim();
const currentEmail = String(
currentState?.email
|| (currentIdentifierType === 'email' ? currentState?.accountIdentifier : '')
|| email
).trim();
const accountIdentifier = currentIdentifierType === 'phone'
? currentPhoneNumber
: currentEmail;
const oauthUrl = await refreshOAuthUrlBeforeStep6(currentState);
if (typeof startOAuthFlowTimeoutWindow === 'function') {
await startOAuthFlowTimeoutWindow({ step: 7, oauthUrl });
await startOAuthFlowTimeoutWindow({ step: completionStep, oauthUrl });
}
const loginTimeoutMs = typeof getOAuthFlowStepTimeoutMs === 'function'
? await getOAuthFlowStepTimeoutMs(180000, {
step: 7,
step: completionStep,
actionLabel: 'OAuth 登录并进入验证码页',
oauthUrl,
})
: 180000;
if (attempt === 1) {
await addLog('步骤 7正在打开最新 OAuth 链接并登录...');
await addLog('正在打开最新 OAuth 链接并登录...', 'info', {
step: completionStep,
stepKey: 'oauth-login',
});
} else {
await addLog(`步骤 7上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn');
await addLog(`上一轮失败后,正在进行第 ${attempt} 次尝试(最多 ${STEP6_MAX_ATTEMPTS} 次)...`, 'warn', {
step: completionStep,
stepKey: 'oauth-login',
});
}
await reuseOrCreateTab('signup-page', oauthUrl);
await reuseOrCreateTab('signup-page', oauthUrl, { forceNew: true });
const result = await sendToContentScriptResilient(
'signup-page',
@@ -84,15 +130,29 @@
step: 7,
source: 'background',
payload: {
email: currentState.email,
email: currentEmail,
phoneNumber: currentPhoneNumber,
countryId: currentState?.signupPhoneCompletedActivation?.countryId
?? currentState?.signupPhoneActivation?.countryId
?? null,
countryLabel: String(
currentState?.signupPhoneCompletedActivation?.countryLabel
|| currentState?.signupPhoneActivation?.countryLabel
|| ''
).trim(),
accountIdentifier,
loginIdentifierType: currentIdentifierType,
password,
visibleStep: completionStep,
},
},
{
timeoutMs: loginTimeoutMs,
responseTimeoutMs: loginTimeoutMs,
retryDelayMs: 700,
logMessage: '步骤 7认证页正在切换,等待页面重新就绪后继续登录...',
logMessage: '认证页正在切换,等待页面重新就绪后继续登录...',
logStep: completionStep,
logStepKey: 'oauth-login',
}
);
@@ -117,11 +177,11 @@
if (isStep6RecoverableResult(result)) {
const reasonMessage = result.message
|| `当前停留在${getLoginAuthStateLabel(result.state)},准备重新执行步骤 7`;
|| `当前停留在${getLoginAuthStateLabel(result.state)},准备重新执行步骤 ${completionStep}`;
throw new Error(reasonMessage);
}
throw new Error('步骤 7:认证页未返回可识别的登录结果。');
throw new Error(`步骤 ${completionStep}:认证页未返回可识别的登录结果。`);
} catch (err) {
throwIfStopped(err);
if (isAddPhoneAuthFailure(err)) {
@@ -129,8 +189,9 @@
}
if (isManagementSecretConfigError(err)) {
await addLog(
`步骤 7检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
'error'
`检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
'error',
{ step: completionStep, stepKey: 'oauth-login' }
);
throw err;
}
@@ -139,11 +200,14 @@
break;
}
await addLog(`步骤 7${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn');
await addLog(`${attempt} 次尝试失败,原因:${getErrorMessage(err)};准备重试...`, 'warn', {
step: completionStep,
stepKey: 'oauth-login',
});
}
}
throw new Error(`步骤 7:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`);
throw new Error(`步骤 ${completionStep}:判断失败后已重试 ${STEP6_MAX_ATTEMPTS - 1} 次,仍未成功。最后原因:${getErrorMessage(lastError)}`);
}
return { executeStep7 };
+48 -26
View File
@@ -31,18 +31,31 @@
return visibleStep >= 10 ? visibleStep : 10;
}
function parseLocalhostCallback(rawUrl) {
function resolveConfirmOauthStep(platformVerifyStep = 10) {
return Number(platformVerifyStep) >= 13 ? 12 : 9;
}
function resolveAuthLoginStep(platformVerifyStep = 10) {
return Number(platformVerifyStep) >= 13 ? 10 : 7;
}
function addStepLog(step, message, level = 'info') {
return addLog(message, level, { step, stepKey: 'platform-verify' });
}
function parseLocalhostCallback(rawUrl, platformVerifyStep = 10) {
const confirmOauthStep = resolveConfirmOauthStep(platformVerifyStep);
let parsed;
try {
parsed = new URL(rawUrl);
} catch {
throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 9。');
throw new Error(`步骤 ${platformVerifyStep} 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 ${confirmOauthStep}`);
}
const code = normalizeString(parsed.searchParams.get('code'));
const state = normalizeString(parsed.searchParams.get('state'));
if (!code || !state) {
throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 9。');
throw new Error(`步骤 ${platformVerifyStep} 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 ${confirmOauthStep}`);
}
return {
@@ -206,18 +219,20 @@
async function executeCpaStep10(state) {
const platformVerifyStep = resolvePlatformVerifyStep(state);
const confirmOauthStep = resolveConfirmOauthStep(platformVerifyStep);
const authLoginStep = resolveAuthLoginStep(platformVerifyStep);
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
throw new Error(`步骤 ${confirmOauthStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmOauthStep}`);
}
if (!state.localhostUrl) {
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmOauthStep}`);
}
if (!state.vpsUrl) {
throw new Error('尚未填写 CPA 地址,请先在侧边栏输入。');
}
if (shouldBypassStep9ForLocalCpa(state)) {
await addLog('步骤 10检测到本地 CPA,且当前策略为“跳过第10步”,本轮不再重复提交回调地址。', 'info');
await addStepLog(platformVerifyStep, '检测到本地 CPA,且当前策略为“跳过平台回调验证”,本轮不再重复提交回调地址。', 'info');
await completeStepFromBackground(platformVerifyStep, {
localhostUrl: state.localhostUrl,
verifiedStatus: 'local-auto',
@@ -225,17 +240,17 @@
return;
}
const callback = parseLocalhostCallback(state.localhostUrl);
const callback = parseLocalhostCallback(state.localhostUrl, platformVerifyStep);
const expectedState = normalizeString(state.cpaOAuthState);
if (expectedState && expectedState !== callback.state) {
throw new Error('CPA 回调 state 与当前授权会话不匹配,请重新执行步骤 7。');
throw new Error(`CPA 回调 state 与当前授权会话不匹配,请重新执行步骤 ${authLoginStep}`);
}
const managementKey = normalizeString(state.vpsPassword);
if (!managementKey) {
throw new Error('尚未配置 CPA 管理密钥,请先在侧边栏填写。');
}
await addLog('步骤 10正在通过 CPA 管理接口提交回调地址...');
await addStepLog(platformVerifyStep, '正在通过 CPA 管理接口提交回调地址...');
try {
const origin = normalizeString(state.cpaManagementOrigin) || deriveCpaManagementOrigin(state.vpsUrl);
const result = await fetchCpaManagementJson(origin, '/v0/management/oauth-callback', {
@@ -250,43 +265,45 @@
const verifiedStatus = normalizeString(result?.message)
|| normalizeString(result?.status_message)
|| 'CPA 已通过接口提交回调';
await addLog(`步骤 10${verifiedStatus}`, 'ok');
await addStepLog(platformVerifyStep, verifiedStatus, 'ok');
await completeStepFromBackground(platformVerifyStep, {
localhostUrl: callback.url,
verifiedStatus,
});
} catch (error) {
const reason = normalizeString(error?.message) || 'unknown error';
await addLog(`步骤 10CPA 接口提交失败:${reason}`, 'error');
await addStepLog(platformVerifyStep, `CPA 接口提交失败:${reason}`, 'error');
throw error;
}
}
async function executeCodex2ApiStep10(state) {
const platformVerifyStep = resolvePlatformVerifyStep(state);
const confirmOauthStep = resolveConfirmOauthStep(platformVerifyStep);
const authLoginStep = resolveAuthLoginStep(platformVerifyStep);
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
throw new Error(`步骤 ${confirmOauthStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmOauthStep}`);
}
if (!state.localhostUrl) {
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmOauthStep}`);
}
if (!state.codex2apiSessionId) {
throw new Error('缺少 Codex2API 会话信息,请重新执行步骤 7。');
throw new Error(`缺少 Codex2API 会话信息,请重新执行步骤 ${authLoginStep}`);
}
if (!normalizeString(state.codex2apiAdminKey)) {
throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
}
const callback = parseLocalhostCallback(state.localhostUrl);
const callback = parseLocalhostCallback(state.localhostUrl, platformVerifyStep);
const expectedState = normalizeString(state.codex2apiOAuthState);
if (expectedState && expectedState !== callback.state) {
throw new Error('Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 7。');
throw new Error(`Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 ${authLoginStep}`);
}
const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl);
const origin = new URL(codex2apiUrl).origin;
await addLog('步骤 10正在向 Codex2API 提交回调并创建账号...');
await addStepLog(platformVerifyStep, '正在向 Codex2API 提交回调并创建账号...');
const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/exchange-code', {
adminKey: state.codex2apiAdminKey,
method: 'POST',
@@ -298,7 +315,7 @@
});
const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功';
await addLog(`步骤 10${verifiedStatus}`, 'ok');
await addStepLog(platformVerifyStep, verifiedStatus, 'ok');
await completeStepFromBackground(platformVerifyStep, {
localhostUrl: callback.url,
verifiedStatus,
@@ -306,12 +323,14 @@
}
async function executeSub2ApiStep10(state) {
const visibleStep = resolvePlatformVerifyStep(state);
const platformVerifyStep = resolvePlatformVerifyStep(state);
const visibleStep = platformVerifyStep;
const confirmOauthStep = resolveConfirmOauthStep(visibleStep);
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
throw new Error(`步骤 ${confirmOauthStep} 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 ${confirmOauthStep}`);
}
if (!state.localhostUrl) {
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
throw new Error(`缺少 localhost 回调地址,请先完成步骤 ${confirmOauthStep}`);
}
if (!state.sub2apiSessionId) {
throw new Error('缺少 SUB2API 会话信息,请重新执行步骤 1。');
@@ -326,7 +345,7 @@
const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl);
const injectFiles = ['content/utils.js', 'content/sub2api-panel.js'];
await addLog('步骤 10正在打开 SUB2API 后台...');
await addStepLog(visibleStep, '正在打开 SUB2API 后台...');
let tabId = await getTabId('sub2api-panel');
const alive = tabId && await isTabAlive('sub2api-panel');
@@ -348,13 +367,14 @@
injectSource: 'sub2api-panel',
});
await addLog(`步骤 ${visibleStep}正在向 SUB2API 提交回调并创建账号...`);
await addStepLog(visibleStep, '正在向 SUB2API 提交回调并创建账号...');
const requestMessage = {
type: 'EXECUTE_STEP',
step: 10,
step: platformVerifyStep,
source: 'background',
payload: {
localhostUrl: state.localhostUrl,
visibleStep,
sub2apiUrl,
sub2apiEmail: state.sub2apiEmail,
sub2apiPassword: state.sub2apiPassword,
@@ -364,6 +384,7 @@
sub2apiSessionId: state.sub2apiSessionId,
sub2apiOAuthState: state.sub2apiOAuthState,
sub2apiGroupId: state.sub2apiGroupId,
sub2apiGroupIds: state.sub2apiGroupIds,
sub2apiDraftName: state.sub2apiDraftName,
},
};
@@ -384,8 +405,9 @@
throw error;
}
await addLog(
`步骤 ${visibleStep}SUB2API 回调交换出现临时网络波动(${error.message}),正在重试 ${attempt + 1}/${maxExchangeAttempts}...`,
'warn'
`SUB2API 回调交换出现临时网络波动(${error.message}),正在重试 ${attempt + 1}/${maxExchangeAttempts}...`,
'warn',
{ step: visibleStep, stepKey: 'platform-verify' }
);
await sleep(1200 * attempt);
}
+8 -3
View File
@@ -2,6 +2,7 @@
root.MultiPageBackgroundPlusReturnConfirm = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundPlusReturnConfirmModule() {
const PAYPAL_SOURCE = 'paypal-flow';
const GOPAY_SOURCE = 'gopay-flow';
const PLUS_CHECKOUT_SOURCE = 'plus-checkout';
const PLUS_RETURN_SETTLE_WAIT_MS = 20000;
@@ -21,6 +22,10 @@
if (paypalTabId && await isTabAlive(PAYPAL_SOURCE)) {
return paypalTabId;
}
const gopayTabId = await getTabId(GOPAY_SOURCE);
if (gopayTabId && await isTabAlive(GOPAY_SOURCE)) {
return gopayTabId;
}
const checkoutTabId = await getTabId(PLUS_CHECKOUT_SOURCE);
if (checkoutTabId) {
return checkoutTabId;
@@ -29,17 +34,17 @@
if (storedTabId) {
return storedTabId;
}
throw new Error('步骤 9:未找到 Plus / PayPal 标签页,无法确认订阅回跳。');
throw new Error('步骤 9:未找到 Plus / PayPal / GoPay 标签页,无法确认订阅回跳。');
}
function isReturnUrl(url = '') {
return /https:\/\/(?:chatgpt\.com|chat\.openai\.com|openai\.com)\//i.test(String(url || ''))
&& !/paypal\./i.test(String(url || ''));
&& !/paypal\.|gopay|gojek|midtrans|xendit|stripe/i.test(String(url || ''));
}
async function executePlusReturnConfirm(state = {}) {
const tabId = await resolveReturnTabId(state);
await addLog('步骤 9:正在等待 PayPal 授权后回跳到 ChatGPT / OpenAI 页面...', 'info');
await addLog('步骤 9:正在等待支付授权后回跳到 ChatGPT / OpenAI 页面...', 'info');
const tab = await waitForTabUrlMatchUntilStopped(tabId, isReturnUrl);
await addLog('步骤 9:已检测到订阅回跳页面,固定等待 20 秒让页面完成加载。', 'info');
await sleepWithStop(PLUS_RETURN_SETTLE_WAIT_MS);
+260 -11
View File
@@ -10,8 +10,11 @@
ensureSignupAuthEntryPageReady,
ensureSignupEntryPageReady,
ensureSignupPostEmailPageReadyInTab,
ensureSignupPostIdentityPageReadyInTab = ensureSignupPostEmailPageReadyInTab,
getTabId,
isTabAlive,
phoneVerificationHelpers = null,
resolveSignupMethod = () => 'email',
resolveSignupEmailForFlow,
sendToContentScriptResilient,
SIGNUP_PAGE_INJECT_FILES,
@@ -26,6 +29,11 @@
return /未找到可用的邮箱输入入口|当前页面没有可用的注册入口,也不在邮箱\/密码页/i.test(message);
}
function isSignupPhoneEntryUnavailableErrorMessage(errorLike) {
const message = getErrorMessage(errorLike);
return /未找到可用的手机号输入入口|当前页面没有可用的手机号注册入口,也不在密码页/i.test(message);
}
function isRetryableStep2TransportErrorMessage(errorLike) {
const message = getErrorMessage(errorLike);
return /Content script on signup-page did not respond in \d+s|Receiving end does not exist|message channel closed|A listener indicated an asynchronous response|port closed before a response was received|did not respond in \d+s/i.test(message);
@@ -55,17 +63,58 @@
}
}
async function shouldForceAuthEntryRetry(tabId) {
if (!Number.isInteger(tabId) || typeof chrome?.tabs?.get !== 'function') {
return false;
function isReadySignupEntryState(state = '') {
const normalized = String(state || '').trim().toLowerCase();
return normalized === 'entry_home'
|| normalized === 'email_entry'
|| normalized === 'phone_entry'
|| normalized === 'password_page';
}
async function getSignupEntryReadyState(tabId) {
if (!Number.isInteger(tabId) || typeof sendToContentScriptResilient !== 'function') {
return '';
}
try {
const tab = await chrome.tabs.get(tabId);
return isLikelyLoggedInChatgptHomeUrl(tab?.url);
const result = await sendToContentScriptResilient('signup-page', {
type: 'ENSURE_SIGNUP_ENTRY_READY',
step: 2,
source: 'background',
payload: {},
}, {
timeoutMs: 12000,
retryDelayMs: 500,
logMessage: '步骤 2:正在检查官网注册入口状态...',
});
if (result?.error) {
return '';
}
return String(result?.state || '').trim().toLowerCase();
} catch {
return '';
}
}
async function isLikelyLoggedInChatgptHomeTab(tabId) {
if (typeof chrome?.tabs?.get !== 'function') {
return false;
}
const readyState = await getSignupEntryReadyState(tabId);
if (isReadySignupEntryState(readyState)) {
return false;
}
const currentUrl = await getTabUrl(tabId);
return isLikelyLoggedInChatgptHomeUrl(currentUrl);
}
async function shouldForceAuthEntryRetry(tabId) {
if (!Number.isInteger(tabId)) {
return false;
}
return isLikelyLoggedInChatgptHomeTab(tabId);
}
async function getTabUrl(tabId) {
@@ -82,8 +131,7 @@
}
async function failStep2OnLoggedInSession(tabId, reasonMessage = '') {
const currentUrl = await getTabUrl(tabId);
if (!isLikelyLoggedInChatgptHomeUrl(currentUrl)) {
if (!(await isLikelyLoggedInChatgptHomeTab(tabId))) {
return false;
}
@@ -94,7 +142,7 @@
throw new Error(message);
}
async function submitSignupEmail(resolvedEmail, options = {}) {
async function sendSignupIdentity(payload = {}, options = {}) {
const {
timeoutMs = 35000,
retryDelayMs = 700,
@@ -106,7 +154,7 @@
type: 'EXECUTE_STEP',
step: 2,
source: 'background',
payload: { email: resolvedEmail },
payload,
}, {
timeoutMs,
retryDelayMs,
@@ -117,9 +165,45 @@
}
}
async function executeStep2(state) {
const resolvedEmail = await resolveSignupEmailForFlow(state);
async function ensureSignupPhoneEntryReady(tabId) {
if (!Number.isInteger(tabId)) {
throw new Error('步骤 2:未找到可用的注册页标签,无法切换到手机号注册入口。');
}
const result = await sendToContentScriptResilient('signup-page', {
type: 'ENSURE_SIGNUP_PHONE_ENTRY_READY',
step: 2,
source: 'background',
payload: {},
}, {
timeoutMs: 30000,
retryDelayMs: 700,
logMessage: '步骤 2:正在打开官网注册入口并切换到手机号注册...',
});
if (result?.error) {
throw new Error(result.error);
}
return result || {};
}
async function submitSignupEmail(resolvedEmail, options = {}) {
return sendSignupIdentity({ email: resolvedEmail }, options);
}
async function submitSignupPhone(phoneNumber, activation, options = {}) {
return sendSignupIdentity({
signupMethod: 'phone',
phoneNumber,
countryId: activation?.countryId ?? null,
countryLabel: String(activation?.countryLabel || '').trim(),
}, {
logMessage: '步骤 2:官网注册入口正在切换,等待手机号注册入口恢复...',
...options,
});
}
async function ensureSignupTabForStep2() {
let signupTabId = await getTabId('signup-page');
if (!signupTabId || !(await isTabAlive('signup-page'))) {
await addLog('步骤 2:未发现可用的注册页标签,正在重新打开 ChatGPT 官网...', 'warn');
@@ -134,6 +218,162 @@
logMessage: '步骤 2:注册入口页内容脚本未就绪,正在等待页面恢复...',
});
}
return signupTabId;
}
function normalizeSignupPhoneActivationForStep2(activation) {
if (typeof phoneVerificationHelpers?.normalizeActivation === 'function') {
return phoneVerificationHelpers.normalizeActivation(activation);
}
if (!activation || typeof activation !== 'object' || Array.isArray(activation)) {
return null;
}
const activationId = String(activation.activationId ?? activation.id ?? activation.activation ?? '').trim();
const phoneNumber = String(activation.phoneNumber ?? activation.number ?? activation.phone ?? '').trim();
if (!activationId || !phoneNumber) {
return null;
}
return {
...activation,
activationId,
phoneNumber,
};
}
function getSignupPhoneNumberFromState(state = {}) {
return String(
state?.signupPhoneNumber
|| (String(state?.accountIdentifierType || '').trim().toLowerCase() === 'phone' ? state?.accountIdentifier : '')
|| ''
).trim();
}
async function resolveSignupPhoneForStep2(state = {}) {
const existingActivation = normalizeSignupPhoneActivationForStep2(state?.signupPhoneActivation);
if (existingActivation?.phoneNumber) {
await addLog(`步骤 2:复用当前注册手机号 ${existingActivation.phoneNumber},不重新获取号码。`);
return {
phoneNumber: existingActivation.phoneNumber,
activation: existingActivation,
};
}
const manualPhoneNumber = getSignupPhoneNumberFromState(state);
if (manualPhoneNumber) {
await addLog(`步骤 2:使用手动填写的注册手机号 ${manualPhoneNumber},本轮不会重新获取号码。`, 'warn');
return {
phoneNumber: manualPhoneNumber,
activation: null,
};
}
if (typeof phoneVerificationHelpers?.prepareSignupPhoneActivation !== 'function') {
throw new Error('手机号注册流程不可用:接码模块尚未初始化。');
}
const activation = await phoneVerificationHelpers.prepareSignupPhoneActivation(state);
return {
phoneNumber: activation.phoneNumber,
activation,
};
}
async function executeSignupPhoneEntry(state) {
let signupTabId = await ensureSignupTabForStep2();
if (await shouldForceAuthEntryRetry(signupTabId)) {
await addLog('步骤 2:检测到当前位于已登录 ChatGPT 首页,先切换认证入口页再提交手机号。', 'warn');
try {
signupTabId = (await ensureSignupAuthEntryPageReady(2)).tabId;
} catch (entryError) {
const entryErrorMessage = getErrorMessage(entryError);
if (await failStep2OnLoggedInSession(signupTabId, entryErrorMessage)) {
return;
}
await addLog('步骤 2:切换认证入口失败,正在重新打开官网入口并重试提交手机号...', 'warn');
signupTabId = (await ensureSignupEntryPageReady(2)).tabId;
}
}
try {
await ensureSignupPhoneEntryReady(signupTabId);
} catch (entryError) {
const entryErrorMessage = getErrorMessage(entryError);
if (await failStep2OnLoggedInSession(signupTabId, entryErrorMessage)) {
return;
}
if (
isSignupPhoneEntryUnavailableErrorMessage(entryErrorMessage)
|| isSignupEntryUnavailableErrorMessage(entryErrorMessage)
|| isRetryableStep2TransportErrorMessage(entryErrorMessage)
) {
await addLog('步骤 2:手机号注册入口尚未就绪,正在重新打开官网入口后重试一次...', 'warn');
signupTabId = (await ensureSignupEntryPageReady(2)).tabId;
await ensureSignupPhoneEntryReady(signupTabId);
} else {
throw entryError;
}
}
const signupPhone = await resolveSignupPhoneForStep2(state);
const { phoneNumber, activation } = signupPhone;
let step2Result = await submitSignupPhone(phoneNumber, activation, {
timeoutMs: 45000,
retryDelayMs: 700,
logMessage: '步骤 2:官网注册入口正在切换,等待手机号注册入口恢复...',
});
if (step2Result?.error) {
const errorMessage = getErrorMessage(step2Result.error);
if (
isSignupPhoneEntryUnavailableErrorMessage(errorMessage)
|| isSignupEntryUnavailableErrorMessage(errorMessage)
|| isRetryableStep2TransportErrorMessage(errorMessage)
) {
await addLog('步骤 2:手机号注册入口不可用或通信超时,正在重新准备手机号注册入口后重试一次...', 'warn');
signupTabId = (await ensureSignupEntryPageReady(2)).tabId;
await ensureSignupPhoneEntryReady(signupTabId);
step2Result = await submitSignupPhone(phoneNumber, activation, {
timeoutMs: 45000,
retryDelayMs: 700,
logMessage: '步骤 2:手机号注册入口已就绪,正在重新提交手机号...',
});
}
}
if (step2Result?.error) {
const finalErrorMessage = getErrorMessage(step2Result.error);
if (
(isSignupEntryUnavailableErrorMessage(finalErrorMessage)
|| isRetryableStep2TransportErrorMessage(finalErrorMessage))
&& await failStep2OnLoggedInSession(signupTabId, finalErrorMessage)
) {
return;
}
if (activation && typeof phoneVerificationHelpers?.cancelSignupPhoneActivation === 'function') {
await phoneVerificationHelpers.cancelSignupPhoneActivation(state, activation).catch(() => {});
}
throw new Error(finalErrorMessage);
}
await addLog(`步骤 2:手机号 ${phoneNumber} 已提交,正在等待页面加载并确认下一步入口...`);
const landingResult = await ensureSignupPostIdentityPageReadyInTab(signupTabId, 2, {
skipUrlWait: Boolean(step2Result?.alreadyOnPasswordPage),
});
await completeStepFromBackground(2, {
accountIdentifierType: 'phone',
accountIdentifier: phoneNumber,
signupPhoneNumber: phoneNumber,
signupPhoneActivation: activation || null,
nextSignupState: landingResult?.state || step2Result?.state || 'password_page',
nextSignupUrl: landingResult?.url || step2Result?.url || '',
skippedPasswordStep: landingResult?.state === 'phone_verification_page' || landingResult?.state === 'profile_page',
});
}
async function executeSignupEmailEntry(state) {
const resolvedEmail = await resolveSignupEmailForFlow(state);
let signupTabId = await ensureSignupTabForStep2();
if (await shouldForceAuthEntryRetry(signupTabId)) {
await addLog('步骤 2:检测到当前位于已登录 ChatGPT 首页,先切换认证入口页再提交邮箱。', 'warn');
@@ -214,12 +454,21 @@
await completeStepFromBackground(2, {
email: resolvedEmail,
accountIdentifierType: 'email',
accountIdentifier: resolvedEmail,
nextSignupState: landingResult?.state || 'password_page',
nextSignupUrl: landingResult?.url || step2Result?.url || '',
skippedPasswordStep: landingResult?.state === 'verification_page',
});
}
async function executeStep2(state) {
if (resolveSignupMethod(state) === 'phone') {
return executeSignupPhoneEntry(state);
}
return executeSignupEmailEntry(state);
}
return { executeStep2 };
}
+43 -3
View File
@@ -339,6 +339,8 @@
timeoutMs = 30000,
retryDelayMs = 700,
logMessage = '',
logStep = null,
logStepKey = '',
} = options;
const start = Date.now();
@@ -399,7 +401,10 @@
if (logMessage && !logged) {
console.warn(LOG_PREFIX, `[ensureContentScriptReadyOnTab] ${source} tab=${tabId} still not ready after ${Date.now() - start}ms`);
await addLog(logMessage, 'warn');
await addLog(logMessage, 'warn', {
step: logStep,
stepKey: logStepKey,
});
logged = true;
}
@@ -527,6 +532,31 @@
}
async function reuseOrCreateTab(source, url, options = {}) {
if (options.forceNew) {
await closeConflictingTabsForSource(source, url);
const tab = await chrome.tabs.create({ url, active: true });
if (options.inject) {
await waitForTabUpdateComplete(tab.id);
if (options.injectSource) {
await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (injectedSource) => {
window.__MULTIPAGE_SOURCE = injectedSource;
},
args: [options.injectSource],
});
}
await chrome.scripting.executeScript({
target: { tabId: tab.id },
files: options.inject,
});
}
await rememberSourceLastUrl(source, url);
return tab.id;
}
const alive = await isTabAlive(source);
if (alive) {
const tabId = await getTabId(source);
@@ -645,6 +675,8 @@
timeoutMs = 30000,
retryDelayMs = 600,
logMessage = '',
logStep = null,
logStepKey = '',
responseTimeoutMs,
} = options;
const start = Date.now();
@@ -676,7 +708,10 @@
lastError = err;
if (logMessage && !logged) {
await addLog(logMessage, 'warn');
await addLog(logMessage, 'warn', {
step: logStep,
stepKey: logStepKey,
});
logged = true;
}
@@ -691,6 +726,8 @@
const {
timeoutMs = 45000,
maxRecoveryAttempts = 2,
logStep = null,
logStepKey = '',
responseTimeoutMs,
} = options;
const start = Date.now();
@@ -720,7 +757,10 @@
lastError = err;
if (!logged) {
await addLog(`步骤 ${message.step}${mail.label} 页面通信异常,正在尝试让邮箱页重新就绪...`, 'warn');
await addLog(`${mail.label} 页面通信异常,正在尝试让邮箱页重新就绪...`, 'warn', {
step: logStep,
stepKey: logStepKey,
});
logged = true;
}
+51 -5
View File
@@ -6,7 +6,7 @@
function createVerificationFlowHelpers(deps = {}) {
const {
addLog,
addLog: rawAddLog = async () => {},
chrome,
closeConflictingTabsForSource,
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
@@ -34,6 +34,33 @@
throwIfStopped,
VERIFICATION_POLL_MAX_ROUNDS,
} = deps;
let activeVerificationLogStep = null;
function normalizeLogStep(value) {
const step = Math.floor(Number(value) || 0);
return step > 0 ? step : null;
}
function normalizeVerificationLogMessage(message) {
return String(message || '')
.replace(/^步骤\s*\d+\s*[:]\s*/, '')
.replace(/^Step\s+\d+\s*[:]\s*/i, '')
.trim();
}
function addLog(message, level = 'info', options = {}) {
const normalizedOptions = options && typeof options === 'object' ? { ...options } : {};
const step = normalizeLogStep(normalizedOptions.step || normalizedOptions.visibleStep)
|| normalizeLogStep(activeVerificationLogStep);
if (step) {
normalizedOptions.step = step;
if (!normalizedOptions.stepKey) {
normalizedOptions.stepKey = step === 4 ? 'fetch-signup-code' : 'fetch-login-code';
}
}
delete normalizedOptions.visibleStep;
return rawAddLog(normalizeVerificationLogMessage(message), level, normalizedOptions);
}
const isRetryableVerificationTransportError = typeof deps.isRetryableContentScriptTransportError === 'function'
? deps.isRetryableContentScriptTransportError
@@ -518,6 +545,8 @@
timeoutMs: responseTimeoutMs,
responseTimeoutMs,
maxRecoveryAttempts: 2,
logStep: activeVerificationLogStep,
logStepKey: step === 4 ? 'fetch-signup-code' : 'fetch-login-code',
}
);
@@ -583,6 +612,8 @@
timeoutMs: 10000,
responseTimeoutMs: 5000,
maxRecoveryAttempts: 1,
logStep: activeVerificationLogStep,
logStepKey: step === 4 ? 'fetch-signup-code' : 'fetch-login-code',
}
);
} catch (_) {
@@ -693,6 +724,8 @@
timeoutMs: timeoutWindow.timeoutMs,
maxRecoveryAttempts: 2,
responseTimeoutMs: timeoutWindow.responseTimeoutMs,
logStep: activeVerificationLogStep,
logStepKey: step === 4 ? 'fetch-signup-code' : 'fetch-login-code',
}
);
@@ -959,6 +992,8 @@
timeoutMs: timeoutWindow.timeoutMs,
maxRecoveryAttempts: 2,
responseTimeoutMs: timeoutWindow.responseTimeoutMs,
logStep: activeVerificationLogStep,
logStepKey: step === 4 ? 'fetch-signup-code' : 'fetch-login-code',
}
);
@@ -1000,6 +1035,8 @@
}
async function submitVerificationCode(step, code, options = {}) {
const completionStep = getCompletionStep(step, options);
const authLoginStep = completionStep >= 11 ? 10 : 7;
const signupTabId = await getTabId('signup-page');
if (!signupTabId) {
throw new Error('认证页面标签页已关闭,无法填写验证码。');
@@ -1028,7 +1065,9 @@
timeoutMs: Math.max(baseResponseTimeoutMs + 15000, 30000),
retryDelayMs: 700,
responseTimeoutMs: baseResponseTimeoutMs,
logMessage: `步骤 ${step}认证页正在切换,等待页面重新就绪后继续确认验证码提交结果...`,
logMessage: '认证页正在切换,等待页面重新就绪后继续确认验证码提交结果...',
logStep: completionStep,
logStepKey: step === 4 ? 'fetch-signup-code' : 'fetch-login-code',
});
} catch (err) {
if (step === 4 && isRetryableVerificationTransportError(err)) {
@@ -1058,9 +1097,15 @@
});
if (fallback.success) {
if (fallback.addPhonePage) {
await addLog('步骤 8验证码提交后通信中断,但页面已进入手机号验证页,按提交成功继续。', 'warn');
await addLog('验证码提交后通信中断,但页面已进入手机号验证页,按提交成功继续。', 'warn', {
step: completionStep,
stepKey: 'fetch-login-code',
});
} else {
await addLog('步骤 8验证码提交后通信中断,但页面已进入 OAuth 授权页,按提交成功继续。', 'warn');
await addLog('验证码提交后通信中断,但页面已进入 OAuth 授权页,按提交成功继续。', 'warn', {
step: completionStep,
stepKey: 'fetch-login-code',
});
}
return {
success: true,
@@ -1072,7 +1117,7 @@
}
if (fallback.restartStep7) {
const urlPart = fallback.url ? ` URL: ${fallback.url}` : '';
throw new Error(`STEP8_RESTART_STEP7::步骤 8:验证码提交后认证页进入登录超时报错页,请回到步骤 7 重新开始。${urlPart}`.trim());
throw new Error(`STEP8_RESTART_STEP7::步骤 ${completionStep}:验证码提交后认证页进入登录超时报错页,请回到步骤 ${authLoginStep} 重新开始。${urlPart}`.trim());
}
}
throw err;
@@ -1092,6 +1137,7 @@
async function resolveVerificationStep(step, state, mail, options = {}) {
const completionStep = getCompletionStep(step, options);
activeVerificationLogStep = completionStep;
const stateKey = getVerificationCodeStateKey(step);
const rejectedCodes = new Set();
const hotmailPollConfig = mail.provider === HOTMAIL_PROVIDER
+766
View File
@@ -0,0 +1,766 @@
// content/gopay-flow.js — GoPay authorization helper.
console.log('[MultiPage:gopay-flow] Content script loaded on', location.href);
const GOPAY_FLOW_LISTENER_SENTINEL = 'data-multipage-gopay-flow-listener';
if (document.documentElement.getAttribute(GOPAY_FLOW_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(GOPAY_FLOW_LISTENER_SENTINEL, '1');
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (
message.type === 'GOPAY_GET_STATE'
|| message.type === 'GOPAY_SUBMIT_PHONE'
|| message.type === 'GOPAY_SUBMIT_OTP'
|| message.type === 'GOPAY_SUBMIT_PIN'
|| message.type === 'GOPAY_CLICK_CONTINUE'
|| message.type === 'GOPAY_GET_CONTINUE_TARGET'
|| message.type === 'GOPAY_CLICK_PAY_NOW'
|| message.type === 'GOPAY_GET_PAY_NOW_TARGET'
) {
resetStopState();
handleGoPayCommand(message).then((result) => {
sendResponse({ ok: true, ...(result || {}) });
}).catch((err) => {
if (isStopError(err)) {
sendResponse({ stopped: true, error: err.message });
return;
}
sendResponse({ error: err.message });
});
return true;
}
});
} else {
console.log('[MultiPage:gopay-flow] 消息监听已存在,跳过重复注册');
}
async function handleGoPayCommand(message) {
switch (message.type) {
case 'GOPAY_GET_STATE':
return inspectGoPayState();
case 'GOPAY_SUBMIT_PHONE':
return submitGoPayPhone(message.payload || {});
case 'GOPAY_SUBMIT_OTP':
return submitGoPayOtp(message.payload || {});
case 'GOPAY_SUBMIT_PIN':
return submitGoPayPin(message.payload || {});
case 'GOPAY_CLICK_CONTINUE':
return clickGoPayContinue();
case 'GOPAY_GET_CONTINUE_TARGET':
return getGoPayContinueTarget();
case 'GOPAY_CLICK_PAY_NOW':
return clickGoPayPayNow();
case 'GOPAY_GET_PAY_NOW_TARGET':
return getGoPayPayNowTarget();
default:
throw new Error(`gopay-flow.js 不处理消息:${message.type}`);
}
}
async function waitUntil(predicate, options = {}) {
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250));
const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || 0));
const startedAt = Date.now();
while (true) {
throwIfStopped();
const value = await predicate();
if (value) {
return value;
}
if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
throw new Error(options.timeoutMessage || `${options.label || 'GoPay 页面状态'}等待超时`);
}
await sleep(intervalMs);
}
}
async function waitForDocumentComplete(options = {}) {
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 8000));
const settleMs = Math.max(0, Math.floor(Number(options.settleMs) || 500));
try {
await waitUntil(() => {
const readyState = String(document.readyState || '').toLowerCase();
return readyState === 'complete'
|| readyState === 'interactive'
|| Boolean(document.querySelector?.('button, input, textarea, a, [role="button"]'))
|| Boolean(normalizeText(document.body?.innerText || document.body?.textContent || ''));
}, {
intervalMs: 200,
timeoutMs,
label: 'GoPay DOM',
});
} catch (_) {
// GoPay linking 页面有时长时间保持 loading;后续定位控件本身还有等待/重试。
}
await sleep(settleMs);
}
function isVisibleElement(el) {
if (!el) return false;
let node = el;
while (node && node.nodeType === 1) {
if (node.hidden || node.getAttribute?.('aria-hidden') === 'true' || node.getAttribute?.('inert') !== null) {
return false;
}
const nodeStyle = window.getComputedStyle(node);
if (
nodeStyle.display === 'none'
|| nodeStyle.visibility === 'hidden'
|| nodeStyle.visibility === 'collapse'
|| Number(nodeStyle.opacity) === 0
) {
return false;
}
node = node.parentElement;
}
const style = window.getComputedStyle(el);
const rect = el.getBoundingClientRect();
return style.display !== 'none'
&& style.visibility !== 'hidden'
&& Number(rect.width) > 0
&& Number(rect.height) > 0;
}
function normalizeText(text = '') {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function getActionText(el) {
return normalizeText([
el?.textContent,
el?.value,
el?.getAttribute?.('data-testid'),
el?.getAttribute?.('aria-label'),
el?.getAttribute?.('title'),
el?.getAttribute?.('placeholder'),
el?.getAttribute?.('name'),
el?.id,
].filter(Boolean).join(' '));
}
function getVisibleControls(selector) {
return Array.from(document.querySelectorAll(selector)).filter(isVisibleElement);
}
function isEnabledControl(el) {
return Boolean(el)
&& !el.disabled
&& el.getAttribute?.('aria-disabled') !== 'true';
}
function getVisibleTextInputs() {
return getVisibleControls('input, textarea')
.filter((input) => {
const type = String(input.getAttribute('type') || input.type || '').trim().toLowerCase();
return isEnabledControl(input) && !['hidden', 'checkbox', 'radio', 'submit', 'button', 'file'].includes(type);
});
}
function findInputByPatterns(patterns) {
return getVisibleTextInputs().find((input) => {
const text = getActionText(input);
return patterns.some((pattern) => pattern.test(text));
}) || null;
}
function findPhoneInput() {
const input = findInputByPatterns([
/gopay|go\s*pay|phone|mobile|whatsapp|wa|nomor|ponsel|telepon|hp/i,
/手机|手机号|电话号码|电话/i,
]);
if (input && !isCountrySearchInput(input)) {
return input;
}
return getVisibleControls('input[type="tel"]').find((candidate) => isEnabledControl(candidate) && !isCountrySearchInput(candidate)) || null;
}
function isCountrySearchInput(input) {
const text = getActionText(input);
return /country|country\s*name|country\s*code|国家|地区|区号/i.test(text);
}
function getPageBodyText() {
return normalizeText(document.body?.innerText || document.body?.textContent || '');
}
function isGoPayOtpPageText() {
if (isGoPayPinPageText()) {
return false;
}
if (getVisibleTextInputs().some((input) => isGoPayPinInput(input))) {
return false;
}
const text = getPageBodyText();
return /otp|one[-\s]*time|kode|verification|whatsapp|验证码|短信/i.test(text);
}
function isGoPayPinPageText() {
const text = getPageBodyText();
return /pin|password|passcode|security|sandi|6\s*digit|masukkin\s+pin|masukkan\s+pin|ketik\s+6\s+digit|enter\s+pin|支付密码/i.test(text)
|| /pin-web-client\.gopayapi\.com|\/auth\/pin|\/payment\/validate-pin|linking-validate-pin/i.test(location.href || '');
}
function isGoPayPinInput(input) {
if (!input || isCountrySearchInput(input)) {
return false;
}
const text = getCombinedElementText(input);
const testId = String(input.getAttribute?.('data-testid') || '').trim();
const type = String(input.getAttribute?.('type') || input.type || '').trim().toLowerCase();
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
const pinPage = isGoPayPinPageText();
const strongPinHint = /password|passcode|security|sandi|支付密码|密码/i.test(text);
const ambiguousPinWidget = /^pin-input/i.test(testId)
|| /pin-input(?:-field)?|(?:^|[\s_-])pin(?:$|[\s_-])/i.test(text);
return strongPinHint
|| (pinPage && (ambiguousPinWidget || type === 'password' || maxLength === 1));
}
function detectGoPayTerminalError(text = getPageBodyText()) {
const normalizedText = normalizeText(text);
if (!normalizedText) return null;
if (/waktunya\s+habis|ulang(?:i)?\s+prosesnya\s+dari\s+awal|time(?:'s|\s+is)?\s+(?:out|expired)|session\s+expired|expired|kedaluwarsa/i.test(normalizedText)) {
return {
code: 'expired',
message: 'GoPay 支付会话已超时,需要重新创建 Plus Checkout。',
rawText: normalizedText.slice(0, 240),
};
}
if (/technical\s+error|don[']t\s+worry|try\s+again|terjadi\s+kesalahan|error\s+teknis/i.test(normalizedText)) {
return {
code: 'technical-error',
message: 'GoPay 页面显示技术错误,需要重新发起支付授权。',
rawText: normalizedText.slice(0, 240),
};
}
if (/payment\s+failed|pembayaran\s+gagal|transaksi\s+gagal|ditolak|declined|failed/i.test(normalizedText)) {
return {
code: 'failed',
message: 'GoPay 页面显示支付失败,需要重新发起支付授权。',
rawText: normalizedText.slice(0, 240),
};
}
return null;
}
function findOtpInput() {
if (isGoPayPinPageText()) {
return null;
}
const input = findInputByPatterns([
/otp|one[-\s]*time|verification|verify|code|kode|whatsapp|wa/i,
/验证码|短信|代码/i,
]);
if (input && !isCountrySearchInput(input) && !isGoPayPinInput(input)) {
return input;
}
if (isGoPayOtpPageText()) {
return getVisibleTextInputs().find((candidate) => !isCountrySearchInput(candidate) && !isGoPayPinInput(candidate)) || null;
}
return null;
}
function getGoPayPinInputs() {
return getVisibleTextInputs().filter((candidate) => {
return isGoPayPinInput(candidate);
});
}
function findPinInput() {
const pinInputs = getGoPayPinInputs();
if (pinInputs[0]) {
return pinInputs[0];
}
if (isGoPayOtpPageText()) {
return null;
}
const input = findInputByPatterns([
/pin|password|passcode|security|sandi|pin-input/i,
/密码|支付密码/i,
]);
if (input && !isCountrySearchInput(input)) {
return input;
}
return getVisibleControls('input[type="password"]').find((candidate) => isEnabledControl(candidate) && !isCountrySearchInput(candidate))
|| null;
}
function findClickableByText(patterns) {
const normalizedPatterns = (Array.isArray(patterns) ? patterns : [patterns]).filter(Boolean);
const candidates = getVisibleControls('button, a, [role="button"], input[type="button"], input[type="submit"]');
return candidates.find((el) => {
if (!isEnabledControl(el)) return false;
const text = getActionText(el);
return normalizedPatterns.some((pattern) => pattern.test(text));
}) || null;
}
function findPayNowButton() {
return findClickableByText([
/^\s*pay\s+now\s*$/i,
/^\s*bayar(?:\s+sekarang)?(?:\s*rp[\s\S]*)?\s*$/i,
/^\s*支付\s*$/i,
/^\s*立即支付\s*$/i,
]);
}
function findContinueButton() {
return findClickableByText([
/continue|next|submit|verify|confirm|pay|authorize|allow|lanjut|lanjutkan|berikut|kirim|bayar|konfirmasi|hubungkan|sambungkan|tautkan|setuju|izinkan|link/i,
/继续|下一步|提交|验证|确认|支付|授权|绑定|关联/i,
]);
}
function describeElement(el) {
if (!el) return '';
const rect = el.getBoundingClientRect?.();
const parts = [String(el.tagName || '').toUpperCase()];
if (el.id) parts.push(`#${el.id}`);
const className = typeof el.className === 'string' ? el.className : el.getAttribute?.('class');
if (className) parts.push(`.${String(className).trim().replace(/\s+/g, '.')}`);
const text = getActionText(el) || normalizeText(el.innerText || el.textContent || '');
if (text) parts.push(`"${text.slice(0, 60)}"`);
if (rect) parts.push(`@${Math.round(rect.left)},${Math.round(rect.top)} ${Math.round(rect.width)}x${Math.round(rect.height)}`);
return parts.filter(Boolean).join(' ');
}
function dispatchPointerMouseSequence(target) {
const rect = target.getBoundingClientRect?.();
const clientX = rect ? Math.round(rect.left + rect.width / 2) : 0;
const clientY = rect ? Math.round(rect.top + rect.height / 2) : 0;
const eventInit = {
bubbles: true,
cancelable: true,
composed: true,
view: window,
detail: 1,
button: 0,
buttons: 1,
clientX,
clientY,
screenX: window.screenX + clientX,
screenY: window.screenY + clientY,
};
if (typeof PointerEvent === 'function') {
target.dispatchEvent(new PointerEvent('pointerover', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
target.dispatchEvent(new PointerEvent('pointerenter', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true, bubbles: false }));
target.dispatchEvent(new PointerEvent('pointermove', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
target.dispatchEvent(new PointerEvent('pointerdown', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
}
target.dispatchEvent(new MouseEvent('mouseover', eventInit));
target.dispatchEvent(new MouseEvent('mouseenter', { ...eventInit, bubbles: false }));
target.dispatchEvent(new MouseEvent('mousemove', eventInit));
target.dispatchEvent(new MouseEvent('mousedown', eventInit));
if (typeof PointerEvent === 'function') {
target.dispatchEvent(new PointerEvent('pointerup', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true, buttons: 0 }));
}
target.dispatchEvent(new MouseEvent('mouseup', { ...eventInit, buttons: 0 }));
target.dispatchEvent(new MouseEvent('click', { ...eventInit, buttons: 0 }));
}
async function humanClickElement(el, options = {}) {
if (!el) {
throw new Error('GoPay 页面未找到可点击元素。');
}
el.scrollIntoView?.({ block: 'center', inline: 'center' });
await sleep(Math.max(0, Number(options.beforeMs) || 120));
try {
el.focus?.({ preventScroll: true });
} catch (_) {
try { el.focus?.(); } catch (__) {}
}
dispatchPointerMouseSequence(el);
await sleep(Math.max(0, Number(options.afterDispatchMs) || 120));
if (typeof el.click === 'function') {
el.click();
}
await sleep(Math.max(0, Number(options.afterMs) || 1000));
}
async function clickContinueIfPresent(options = {}) {
const button = findContinueButton();
if (!button) {
return { clicked: false, target: '' };
}
await humanClickElement(button, options);
return { clicked: true, target: describeElement(button) };
}
function normalizePhoneNumber(value = '') {
return String(value || '').trim().replace(/[^\d+]/g, '');
}
function normalizeGoPayCountryCode(value = '') {
const normalized = String(value || '').trim().replace(/[^\d+]/g, '');
const digits = normalized.replace(/\D/g, '');
return digits ? `+${digits}` : '+86';
}
function getCountryCodeDigits(value = '') {
return normalizeGoPayCountryCode(value).replace(/\D/g, '');
}
function normalizeGoPayNationalPhone(value = '', countryCode = '+86') {
const countryDigits = getCountryCodeDigits(countryCode);
let digits = normalizePhoneNumber(value).replace(/\D/g, '');
if (countryDigits && digits.startsWith(countryDigits)) {
digits = digits.slice(countryDigits.length);
}
return digits;
}
function getCombinedElementText(el) {
return normalizeText([
getActionText(el),
el?.innerText,
el?.textContent,
typeof el?.className === 'string' ? el.className : el?.getAttribute?.('class'),
].filter(Boolean).join(' '));
}
function robustClick(el) {
if (!el) return;
el.scrollIntoView?.({ block: 'center', inline: 'center' });
try {
el.focus?.();
} catch (_) {}
const eventInit = { bubbles: true, cancelable: true, view: window };
if (typeof PointerEvent === 'function') {
el.dispatchEvent(new PointerEvent('pointerdown', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
}
el.dispatchEvent(new MouseEvent('mousedown', eventInit));
if (typeof PointerEvent === 'function') {
el.dispatchEvent(new PointerEvent('pointerup', { ...eventInit, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
}
el.dispatchEvent(new MouseEvent('mouseup', eventInit));
el.dispatchEvent(new MouseEvent('click', eventInit));
if (typeof el.click === 'function') {
el.click();
}
}
function readSelectedCountryCodeText() {
const candidates = getVisibleControls('.phone-code, .phone-code-wrapper, [class*="phone-code"], button, [role="button"], [tabindex]');
for (const candidate of candidates) {
const match = getCombinedElementText(candidate).match(/\+\d{1,4}/);
if (match) return normalizeGoPayCountryCode(match[0]);
}
const bodyMatch = normalizeText(document.body?.innerText || document.body?.textContent || '').match(/Phone number:\s*(\+\d{1,4})/i);
return bodyMatch ? normalizeGoPayCountryCode(bodyMatch[1]) : '';
}
function findCountryCodeToggle() {
const preferred = getVisibleControls('.phone-code-wrapper, [class*="phone-code-wrapper"], .phone-code, [class*="phone-code"]')
.find((el) => /\+\d{1,4}/.test(getCombinedElementText(el)));
if (preferred) return preferred;
return getVisibleControls('button, [role="button"], [tabindex], div, span')
.find((el) => /\+\d{1,4}/.test(getCombinedElementText(el)) && /phone|code|country|\+\d{1,4}/i.test(getCombinedElementText(el))) || null;
}
function findCountryCodeOption(countryCode = '+86') {
const normalized = normalizeGoPayCountryCode(countryCode);
const digits = getCountryCodeDigits(normalized);
const countryAliases = {
'1': [/United States|USA|Canada|美国|加拿大/i],
'60': [/Malaysia|马来西亚/i],
'62': [/Indonesia|印尼|印度尼西亚/i],
'63': [/Philippines|菲律宾/i],
'65': [/Singapore|新加坡/i],
'66': [/Thailand|泰国/i],
'84': [/Vietnam|越南/i],
'86': [/China|中国|Mainland/i],
'91': [/India|印度/i],
'852': [/Hong Kong|香港/i],
'853': [/Macau|Macao|澳门/i],
'886': [/Taiwan|台湾/i],
}[digits] || [];
const controls = getVisibleControls('li.country-item, .country-item, [class*="country-item"], [role="option"], li, button, [role="button"], a, [tabindex], div, span')
.map((el) => el.closest?.('.country-item') || el)
.filter((el, index, list) => el && list.indexOf(el) === index)
.filter((el) => {
const rect = el.getBoundingClientRect();
const text = getCombinedElementText(el);
const className = typeof el.className === 'string' ? el.className : el.getAttribute?.('class') || '';
return text.includes(normalized)
&& rect.width > 20
&& rect.height > 8
&& rect.width < Math.max(480, window.innerWidth * 0.8)
&& rect.height < Math.max(120, window.innerHeight * 0.35)
&& !/phone-number-input-wrapper|gopay-tokenization-content|asphalt-theme|search-country|country-list/i.test(className);
});
const matchesAlias = (el) => {
const text = getCombinedElementText(el);
return countryAliases.some((pattern) => pattern.test(text));
};
return controls.find((el) => /country-item/i.test(typeof el.className === 'string' ? el.className : el.getAttribute?.('class') || '') && matchesAlias(el))
|| controls.find((el) => String(el.tagName || '').toUpperCase() === 'LI' && matchesAlias(el))
|| controls.find((el) => el.getAttribute?.('role') === 'option' && matchesAlias(el))
|| controls.find(matchesAlias)
|| null;
}
async function ensureGoPayCountryCode(countryCode = '+86') {
const normalized = normalizeGoPayCountryCode(countryCode);
const selected = readSelectedCountryCodeText();
if (selected === normalized) {
return { changed: false, countryCode: normalized, selected };
}
const toggle = findCountryCodeToggle();
if (!toggle) {
throw new Error(`GoPay 页面未找到国家区号切换控件,当前识别区号:${selected || '未知'},目标区号:${normalized}`);
}
robustClick(toggle);
await sleep(500);
const countryDropdown = document.querySelector('.search-country');
if (countryDropdown && window.getComputedStyle(countryDropdown).display === 'none') {
countryDropdown.style.display = 'block';
await sleep(100);
}
const countrySearchInput = getVisibleTextInputs().find(isCountrySearchInput);
if (countrySearchInput) {
fillInput(countrySearchInput, normalized);
await sleep(300);
}
const option = await waitUntil(() => findCountryCodeOption(normalized), {
label: `GoPay 国家区号 ${normalized}`,
intervalMs: 250,
timeoutMs: 8000,
});
robustClick(option);
await sleep(500);
const nextSelected = readSelectedCountryCodeText();
if (nextSelected === normalized && countryDropdown) {
countryDropdown.style.display = 'none';
}
if (nextSelected !== normalized) {
throw new Error(`GoPay 国家区号切换失败:目标 ${normalized},当前 ${nextSelected || '未知'}`);
}
return {
changed: true,
countryCode: normalized,
selected: nextSelected,
};
}
function normalizeOtp(value = '') {
return String(value || '').trim().replace(/[^\d]/g, '');
}
function fillDigitInputs(inputs = [], code = '') {
const normalizedCode = normalizeOtp(code);
if (!normalizedCode || !inputs.length) return false;
normalizedCode.split('').forEach((digit, index) => {
const input = inputs[index];
if (!input) return;
try {
input.focus?.();
} catch (_) {}
input.dispatchEvent(new KeyboardEvent('keydown', { key: digit, code: `Digit${digit}`, bubbles: true, cancelable: true }));
fillInput(input, digit);
input.dispatchEvent(new KeyboardEvent('keyup', { key: digit, code: `Digit${digit}`, bubbles: true, cancelable: true }));
});
return true;
}
function fillVisiblePinInputs(pin = '') {
const normalizedPin = normalizeOtp(pin);
if (!normalizedPin) return false;
const pinInputs = getGoPayPinInputs();
const digitInputs = pinInputs.filter((input) => {
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
return maxLength > 0 && maxLength <= 1;
});
if (digitInputs.length >= Math.min(4, normalizedPin.length)) {
return fillDigitInputs(digitInputs, normalizedPin);
}
const input = findPinInput() || pinInputs[0];
if (!input) return false;
fillInput(input, normalizedPin);
return true;
}
function fillVisibleOtpInputs(code = '') {
const normalizedCode = normalizeOtp(code);
if (!normalizedCode) return false;
if (isGoPayPinPageText() || getVisibleTextInputs().some((input) => isGoPayPinInput(input))) {
return false;
}
const otpInputs = getVisibleTextInputs()
.filter((input) => {
if (isGoPayPinInput(input)) return false;
const text = getActionText(input);
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
return /otp|code|kode|verification|验证码|短信/i.test(text)
|| (maxLength > 0 && maxLength <= 1)
|| (maxLength > 1 && maxLength <= 8);
});
const digitInputs = otpInputs.filter((input) => {
const maxLength = Number(input.getAttribute?.('maxlength') || input.maxLength || 0);
return maxLength > 0 && maxLength <= 1;
});
if (digitInputs.length >= Math.min(4, normalizedCode.length)) {
return fillDigitInputs(digitInputs, normalizedCode);
}
const input = findOtpInput() || otpInputs[0];
if (!input) return false;
fillInput(input, normalizedCode);
return true;
}
async function submitGoPayPhone(payload = {}) {
await waitForDocumentComplete();
const countryCode = normalizeGoPayCountryCode(payload.countryCode || payload.gopayCountryCode || '+86');
const phone = normalizeGoPayNationalPhone(payload.phone || payload.gopayPhone || '', countryCode);
if (!phone) {
throw new Error('GoPay 手机号为空,请先在侧边栏配置。');
}
const countryResult = await ensureGoPayCountryCode(countryCode);
const input = await waitUntil(() => findPhoneInput(), {
label: 'GoPay 手机号输入框',
intervalMs: 250,
timeoutMs: 15000,
});
fillInput(input, phone);
const clickResult = await clickContinueIfPresent();
return {
phoneSubmitted: true,
countryCode,
countryChanged: Boolean(countryResult.changed),
clicked: Boolean(clickResult.clicked),
clickTarget: clickResult.target || '',
phase: 'phone_submitted',
};
}
async function submitGoPayOtp(payload = {}) {
await waitForDocumentComplete();
const code = normalizeOtp(payload.code || payload.otp || '');
if (!code) {
throw new Error('GoPay WhatsApp 验证码为空。');
}
const filled = await waitUntil(() => fillVisibleOtpInputs(code), {
label: 'GoPay 验证码输入框',
intervalMs: 250,
timeoutMs: 15000,
});
const clickResult = await clickContinueIfPresent();
return {
otpSubmitted: Boolean(filled),
clicked: Boolean(clickResult.clicked),
clickTarget: clickResult.target || '',
phase: 'otp_submitted',
};
}
async function submitGoPayPin(payload = {}) {
await waitForDocumentComplete();
const pin = normalizeOtp(payload.pin || payload.gopayPin || '');
if (!pin) {
throw new Error('GoPay PIN 为空,请先在侧边栏配置。');
}
const filled = await waitUntil(() => fillVisiblePinInputs(pin), {
label: 'GoPay PIN 输入框',
intervalMs: 250,
timeoutMs: 15000,
});
const clickResult = await clickContinueIfPresent();
return {
pinSubmitted: Boolean(filled),
clicked: Boolean(clickResult.clicked),
clickTarget: clickResult.target || '',
phase: 'pin_submitted',
};
}
function getElementClickRect(el) {
if (!el) return null;
const rect = el.getBoundingClientRect?.();
if (!rect || !Number.isFinite(rect.left) || !Number.isFinite(rect.top)) {
return null;
}
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
};
}
function getGoPayContinueTarget() {
const button = findContinueButton();
return {
found: Boolean(button),
target: describeElement(button),
rect: getElementClickRect(button),
};
}
function getGoPayPayNowTarget() {
const button = findPayNowButton();
return {
found: Boolean(button),
target: describeElement(button),
rect: getElementClickRect(button),
};
}
async function clickGoPayContinue() {
await waitForDocumentComplete();
const clickResult = await clickContinueIfPresent({ afterMs: 1200 });
return { clicked: Boolean(clickResult.clicked), clickTarget: clickResult.target || '' };
}
async function clickGoPayPayNow() {
await waitForDocumentComplete();
const button = findPayNowButton();
if (!button) {
return { clicked: false, clickTarget: '' };
}
await humanClickElement(button, { afterMs: 1500 });
return { clicked: true, clickTarget: describeElement(button) };
}
function inspectGoPayState() {
const bodyText = normalizeText(document.body?.innerText || document.body?.textContent || '');
const phoneInput = findPhoneInput();
const otpInput = findOtpInput();
const pinInput = findPinInput();
const payNowButton = findPayNowButton();
const continueButton = findContinueButton();
const terminalError = detectGoPayTerminalError(bodyText);
const successTextMatched = /success|successful|completed|selesai|berhasil|approved|authorized|支付成功|绑定成功|已授权/i.test(bodyText);
const completed = !phoneInput && !otpInput && !pinInput && successTextMatched;
const selectedCountryCode = readSelectedCountryCodeText();
return {
url: location.href,
readyState: document.readyState,
selectedCountryCode,
hasPhoneInput: Boolean(phoneInput),
hasOtpInput: Boolean(otpInput),
hasPinInput: Boolean(pinInput),
hasPayNowButton: Boolean(payNowButton),
hasContinueButton: Boolean(continueButton),
hasTerminalError: Boolean(terminalError),
terminalError,
completed,
textPreview: bodyText.slice(0, 500),
inputHints: getVisibleTextInputs().map((input) => getActionText(input).slice(0, 120)).filter(Boolean).slice(0, 12),
};
}
+41 -3
View File
@@ -26,6 +26,8 @@
const PHONE_RESEND_THROTTLED_PATTERN = /tried\s+to\s+resend\s+too\s+many\s+times|please\s+try\s+again\s+later|too\s+many\s+resend|resend\s+too\s+many|发送.*过于频繁|稍后再试|重试次数过多/i;
const PHONE_ROUTE_405_PATTERN = /405\s+method\s+not\s+allowed|route\s+error.*405|did\s+not\s+provide\s+an?\s+[`'"]?action|post\s+request\s+to\s+["']?\/phone-verification/i;
const PHONE_ROUTE_405_MAX_RECOVERY_CLICKS = 3;
const rootScope = typeof self !== 'undefined' ? self : globalThis;
const phoneCountryUtils = rootScope?.MultiPagePhoneCountryUtils || globalThis?.MultiPagePhoneCountryUtils || {};
let lastPhoneRoute405RecoveryFailedAt = 0;
let activePhoneResendPromise = null;
@@ -36,6 +38,9 @@
}
function normalizePhoneDigits(value) {
if (typeof phoneCountryUtils.normalizePhoneDigits === 'function') {
return phoneCountryUtils.normalizePhoneDigits(value);
}
let digits = String(value || '').replace(/\D+/g, '');
if (digits.startsWith('00')) {
digits = digits.slice(2);
@@ -48,27 +53,39 @@
}
function normalizeCountryLabel(value) {
if (typeof phoneCountryUtils.normalizeCountryLabel === 'function') {
return phoneCountryUtils.normalizeCountryLabel(value);
}
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/&/g, ' and ')
.replace(/[^\w\s]/g, ' ')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
function getOptionLabel(option) {
if (typeof phoneCountryUtils.getOptionLabel === 'function') {
return phoneCountryUtils.getOptionLabel(option);
}
return String(option?.textContent || option?.label || '')
.replace(/\s+/g, ' ')
.trim();
}
function normalizeCountryOptionValue(value) {
if (typeof phoneCountryUtils.normalizeCountryOptionValue === 'function') {
return phoneCountryUtils.normalizeCountryOptionValue(value);
}
return String(value || '').trim().toUpperCase();
}
function getRegionDisplayName(regionCode, locale) {
if (typeof phoneCountryUtils.getRegionDisplayName === 'function') {
return phoneCountryUtils.getRegionDisplayName(regionCode, locale);
}
const normalizedRegionCode = normalizeCountryOptionValue(regionCode);
const normalizedLocale = String(locale || '').trim();
if (!/^[A-Z]{2}$/.test(normalizedRegionCode) || !normalizedLocale || typeof Intl?.DisplayNames !== 'function') {
@@ -84,6 +101,14 @@
}
function getCountryOptionMatchLabels(option) {
if (typeof phoneCountryUtils.getOptionMatchLabels === 'function') {
return phoneCountryUtils.getOptionMatchLabels(option, {
document: typeof document !== 'undefined' ? document : null,
navigator: rootScope?.navigator || globalThis?.navigator || null,
getOptionLabel,
});
}
const labels = new Set();
const pushLabel = (value) => {
const label = String(value || '').replace(/\s+/g, ' ').trim();
@@ -128,8 +153,11 @@
}
function extractDialCodeFromText(value) {
const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/);
return String(match?.[1] || match?.[2] || '').trim();
if (typeof phoneCountryUtils.extractDialCodeFromText === 'function') {
return phoneCountryUtils.extractDialCodeFromText(value);
}
const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*\(\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/);
return String(match?.[1] || match?.[2] || match?.[3] || '').trim();
}
function getCountryButtonText() {
@@ -240,6 +268,13 @@
if (!select) {
return null;
}
if (typeof phoneCountryUtils.findOptionByCountryLabel === 'function') {
return phoneCountryUtils.findOptionByCountryLabel(select.options, countryLabel, {
document: typeof document !== 'undefined' ? document : null,
navigator: rootScope?.navigator || globalThis?.navigator || null,
getOptionLabel,
});
}
const normalizedTarget = normalizeCountryLabel(countryLabel);
if (!normalizedTarget) {
return null;
@@ -267,6 +302,9 @@
if (!select) {
return null;
}
if (typeof phoneCountryUtils.findOptionByPhoneNumber === 'function') {
return phoneCountryUtils.findOptionByPhoneNumber(select.options, phoneNumber, { getOptionLabel });
}
const digits = normalizePhoneDigits(phoneNumber);
if (!digits) {
return null;
+237
View File
@@ -0,0 +1,237 @@
(function attachPhoneCountryUtils(root, factory) {
root.MultiPagePhoneCountryUtils = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createPhoneCountryUtils() {
const KNOWN_DIAL_CODES = Object.freeze([
'1246', '1264', '1268', '1284', '1340', '1345', '1441', '1473', '1649', '1664', '1670', '1671', '1684',
'1721', '1758', '1767', '1784', '1809', '1829', '1849', '1868', '1869', '1876',
'971', '962', '886', '880', '856', '855', '852', '853', '673', '672', '670', '599', '598', '597', '596',
'595', '594', '593', '592', '591', '590', '509', '508', '507', '506', '505', '504', '503', '502', '501',
'423', '421', '420', '389', '387', '386', '385', '383', '382', '381', '380', '379', '378', '377', '376',
'375', '374', '373', '372', '371', '370', '359', '358', '357', '356', '355', '354', '353', '352', '351',
'350', '299', '298', '297', '291', '290', '269', '268', '267', '266', '265', '264', '263', '262', '261',
'260', '258', '257', '256', '255', '254', '253', '252', '251', '250', '249', '248', '247', '246', '245',
'244', '243', '242', '241', '240', '239', '238', '237', '236', '235', '234', '233', '232', '231', '230',
'229', '228', '227', '226', '225', '224', '223', '222', '221', '220', '218', '216', '213', '212', '211',
'98', '95', '94', '93', '92', '91', '90', '89', '88', '86', '84', '82', '81', '66', '65', '64', '63',
'62', '61', '60', '58', '57', '56', '55', '54', '53', '52', '51', '49', '48', '47', '46', '45', '44',
'43', '41', '40', '39', '36', '34', '33', '32', '31', '30', '27', '20', '7', '1',
]);
function normalizePhoneDigits(value) {
let digits = String(value || '').replace(/\D+/g, '');
if (digits.startsWith('00')) {
digits = digits.slice(2);
}
return digits;
}
function extractDialCodeFromText(value) {
const match = String(value || '').match(/\(\+\s*(\d{1,4})\s*\)|\+\s*\(\s*(\d{1,4})\s*\)|\+\s*(\d{1,4})\b/);
return String(match?.[1] || match?.[2] || match?.[3] || '').trim();
}
function normalizeCountryLabel(value) {
return String(value || '')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/&/g, ' and ')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
function normalizeCountryOptionValue(value) {
return String(value || '').trim().toUpperCase();
}
function getCountryLabelAliases(value) {
const aliases = new Set();
const addAlias = (alias) => {
const normalized = normalizeCountryLabel(alias);
if (normalized) {
aliases.add(normalized);
}
};
const raw = String(value || '').trim();
addAlias(raw);
const normalized = normalizeCountryLabel(raw);
const compact = normalized.replace(/\s+/g, '');
if (
/(?:^|\s)(?:gb|uk)(?:\s|$)/i.test(raw)
|| /england|united\s*kingdom|great\s*britain|\bbritain\b/i.test(raw)
|| /英国|英格兰|大不列颠/.test(raw)
|| ['gb', 'uk', 'england', 'unitedkingdom', 'greatbritain', 'britain'].includes(compact)
) {
[
'GB',
'UK',
'United Kingdom',
'Great Britain',
'Britain',
'England',
'英国',
'英格兰',
'大不列颠',
].forEach(addAlias);
}
return Array.from(aliases);
}
function getRegionDisplayName(regionCode, locale) {
const normalizedRegionCode = normalizeCountryOptionValue(regionCode);
const normalizedLocale = String(locale || '').trim();
if (!/^[A-Z]{2}$/.test(normalizedRegionCode) || !normalizedLocale || typeof Intl?.DisplayNames !== 'function') {
return '';
}
try {
return String(
new Intl.DisplayNames([normalizedLocale], { type: 'region' }).of(normalizedRegionCode) || ''
).trim();
} catch {
return '';
}
}
function getOptionLabel(option) {
return String(option?.textContent || option?.label || '')
.replace(/\s+/g, ' ')
.trim();
}
function getOptionMatchLabels(option, options = {}) {
const labels = new Set();
const pushLabel = (value) => {
const label = String(value || '').replace(/\s+/g, ' ').trim();
if (label) {
labels.add(label);
}
};
const getLabel = typeof options.getOptionLabel === 'function'
? options.getOptionLabel
: getOptionLabel;
pushLabel(getLabel(option));
const regionCode = normalizeCountryOptionValue(option?.value);
if (/^[A-Z]{2}$/.test(regionCode)) {
pushLabel(regionCode);
pushLabel(getRegionDisplayName(regionCode, 'en'));
const pageLocale = String(
options.pageLocale
|| options.document?.documentElement?.lang
|| options.document?.documentElement?.getAttribute?.('lang')
|| options.navigator?.language
|| ''
).trim();
if (pageLocale && !/^en(?:[-_]|$)/i.test(pageLocale)) {
pushLabel(getRegionDisplayName(regionCode, pageLocale));
}
}
return Array.from(labels);
}
function resolveDialCodeFromPhoneNumber(phoneNumber = '', texts = []) {
const digits = normalizePhoneDigits(phoneNumber);
if (!digits) {
return '';
}
const textDialCodes = texts
.map((text) => normalizePhoneDigits(extractDialCodeFromText(text)))
.filter((dialCode) => dialCode && digits.startsWith(dialCode) && digits.length > dialCode.length)
.sort((left, right) => right.length - left.length);
if (textDialCodes[0]) {
return textDialCodes[0];
}
return KNOWN_DIAL_CODES.find((code) => digits.startsWith(code) && digits.length > code.length) || '';
}
function findOptionByCountryLabel(options, countryLabel, config = {}) {
const source = Array.from(options || []);
const normalizedTargets = getCountryLabelAliases(countryLabel);
if (source.length === 0 || normalizedTargets.length === 0) {
return null;
}
return source.find((option) => (
getOptionMatchLabels(option, config).some((label) => normalizedTargets.includes(normalizeCountryLabel(label)))
))
|| source.find((option) => {
const normalizedLabels = getOptionMatchLabels(option, config)
.map((label) => normalizeCountryLabel(label))
.filter(Boolean);
return normalizedLabels.some((optionLabel) => normalizedTargets.some((normalizedTarget) => (
optionLabel.length > 2
&& normalizedTarget.length > 2
&& (optionLabel.includes(normalizedTarget) || normalizedTarget.includes(optionLabel))
)));
})
|| null;
}
function findOptionByPhoneNumber(options, phoneNumber, config = {}) {
const source = Array.from(options || []);
const digits = normalizePhoneDigits(phoneNumber);
if (source.length === 0 || !digits) {
return null;
}
const getLabel = typeof config.getOptionLabel === 'function'
? config.getOptionLabel
: getOptionLabel;
let bestMatch = null;
let bestDialCodeLength = 0;
for (const option of source) {
const dialCode = normalizePhoneDigits(extractDialCodeFromText(getLabel(option)));
if (!dialCode || !digits.startsWith(dialCode) || dialCode.length <= bestDialCodeLength) {
continue;
}
bestMatch = option;
bestDialCodeLength = dialCode.length;
}
return bestMatch;
}
function findElementByDialCode(elements, phoneNumber, config = {}) {
const source = Array.from(elements || []);
const digits = normalizePhoneDigits(phoneNumber);
if (source.length === 0 || !digits) {
return null;
}
const getText = typeof config.getText === 'function' ? config.getText : getOptionLabel;
let bestMatch = null;
let bestDialCodeLength = 0;
for (const element of source) {
const dialCode = normalizePhoneDigits(extractDialCodeFromText(getText(element)));
if (!dialCode || !digits.startsWith(dialCode) || dialCode.length <= bestDialCodeLength) {
continue;
}
bestMatch = element;
bestDialCodeLength = dialCode.length;
}
return bestMatch;
}
return {
extractDialCodeFromText,
findElementByDialCode,
findOptionByCountryLabel,
findOptionByPhoneNumber,
getCountryLabelAliases,
getOptionLabel,
getOptionMatchLabels,
getRegionDisplayName,
normalizeCountryLabel,
normalizeCountryOptionValue,
normalizePhoneDigits,
resolveDialCodeFromPhoneNumber,
};
});
+471 -90
View File
@@ -5,7 +5,7 @@ console.log('[MultiPage:plus-checkout] Content script loaded on', location.href)
window.__MULTIPAGE_PLUS_CHECKOUT_READY__ = true;
const PLUS_CHECKOUT_LISTENER_SENTINEL = 'data-multipage-plus-checkout-listener';
const PLUS_CHECKOUT_BASE_PAYLOAD = {
const PLUS_CHECKOUT_PAYLOAD_BASE = {
entry_point: 'all_plans_pricing_modal',
plan_name: 'chatgptplusplan',
checkout_ui_mode: 'custom',
@@ -33,6 +33,32 @@ const PLUS_CHECKOUT_CONFIGS = {
},
};
const PAYPAL_DIAGNOSTIC_LOG_INTERVAL_MS = 5000;
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PAYMENT_METHOD_CONFIGS = {
[PLUS_PAYMENT_METHOD_PAYPAL]: {
id: PLUS_PAYMENT_METHOD_PAYPAL,
label: 'PayPal',
diagnosticLabel: 'PayPal',
checkoutMerchantPath: 'openai_ie',
billingDetails: {
country: 'DE',
currency: 'EUR',
},
patterns: [/paypal/i],
},
[PLUS_PAYMENT_METHOD_GOPAY]: {
id: PLUS_PAYMENT_METHOD_GOPAY,
label: 'GoPay',
diagnosticLabel: 'GoPay',
checkoutMerchantPath: 'openai_llc',
billingDetails: {
country: 'ID',
currency: 'IDR',
},
patterns: [/gopay|go\s*pay/i],
},
};
if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL, '1');
@@ -42,6 +68,7 @@ if (document.documentElement.getAttribute(PLUS_CHECKOUT_LISTENER_SENTINEL) !== '
message.type === 'CREATE_PLUS_CHECKOUT'
|| message.type === 'FILL_PLUS_BILLING_AND_SUBMIT'
|| message.type === 'PLUS_CHECKOUT_SELECT_PAYPAL'
|| message.type === 'PLUS_CHECKOUT_SELECT_GOPAY'
|| message.type === 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS'
|| message.type === 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY'
|| message.type === 'PLUS_CHECKOUT_SELECT_ADDRESS_SUGGESTION'
@@ -73,7 +100,9 @@ async function handlePlusCheckoutCommand(message) {
case 'FILL_PLUS_BILLING_AND_SUBMIT':
return fillPlusBillingAndSubmit(message.payload || {});
case 'PLUS_CHECKOUT_SELECT_PAYPAL':
return selectPlusPayPalPaymentMethod();
return selectPlusPayPalPaymentMethod(message.payload || {});
case 'PLUS_CHECKOUT_SELECT_GOPAY':
return selectPlusGoPayPaymentMethod(message.payload || {});
case 'PLUS_CHECKOUT_FILL_BILLING_ADDRESS':
return fillPlusBillingAddress(message.payload || {});
case 'PLUS_CHECKOUT_FILL_ADDRESS_QUERY':
@@ -83,9 +112,9 @@ async function handlePlusCheckoutCommand(message) {
case 'PLUS_CHECKOUT_ENSURE_BILLING_ADDRESS':
return ensurePlusStructuredBillingAddress(message.payload || {});
case 'PLUS_CHECKOUT_CLICK_SUBSCRIBE':
return clickPlusSubscribe();
return clickPlusSubscribe(message.payload || {});
case 'PLUS_CHECKOUT_GET_STATE':
return inspectPlusCheckoutState();
return inspectPlusCheckoutState(message.payload || {});
default:
throw new Error(`plus-checkout.js 不处理消息:${message.type}`);
}
@@ -94,12 +123,17 @@ async function handlePlusCheckoutCommand(message) {
async function waitUntil(predicate, options = {}) {
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 250));
const label = String(options.label || '条件').trim() || '条件';
const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || 0));
const startedAt = Date.now();
while (true) {
throwIfStopped();
const value = await predicate();
if (value) {
return value;
}
if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
throw new Error(`${label}等待超时`);
}
await sleep(intervalMs);
}
}
@@ -126,24 +160,97 @@ function normalizeText(text = '') {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function normalizePlusPaymentMethod(value = '') {
return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal';
function parseLocalizedAmount(rawValue = '') {
const raw = normalizeText(rawValue);
const match = raw.match(/(?:[$€£¥]\s*)?([+-]?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d{1,2})|[+-]?\d+(?:[.,]\d{1,2})?)(?:\s*[$€£¥])?/);
if (!match) return null;
let numericText = String(match[1] || '').trim();
const lastComma = numericText.lastIndexOf(',');
const lastDot = numericText.lastIndexOf('.');
if (lastComma > -1 && lastDot > -1) {
const decimalSeparator = lastComma > lastDot ? ',' : '.';
const thousandsSeparator = decimalSeparator === ',' ? '.' : ',';
numericText = numericText
.replace(new RegExp(`\\${thousandsSeparator}`, 'g'), '')
.replace(decimalSeparator, '.');
} else if (lastComma > -1) {
numericText = numericText.replace(',', '.');
}
const amount = Number(numericText.replace(/[^\d.+-]/g, ''));
return Number.isFinite(amount)
? {
amount,
raw: match[0],
}
: null;
}
function buildPlusCheckoutConfig(payload = {}) {
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
const config = PLUS_CHECKOUT_CONFIGS[paymentMethod] || PLUS_CHECKOUT_CONFIGS.paypal;
function getTextAfterTodayDueLabel(text = '') {
const normalized = normalizeText(text);
const match = normalized.match(/(?:今日应付金额|今日应付|今天应付|amount\s*due\s*today|due\s*today|today'?s\s*total|total\s*due\s*today)/i);
if (!match) return '';
return normalized.slice((match.index || 0) + match[0].length).trim();
}
function getCheckoutAmountSummary() {
const elements = getVisibleControls('div, span, p, strong, b');
const labelPattern = /今日应付金额|今日应付|今天应付|amount\s*due\s*today|due\s*today|today'?s\s*total|total\s*due\s*today/i;
const amountPattern = /[$€£¥]\s*[+-]?\d|[+-]?\d+(?:[.,]\d{1,2})?\s*[$€£¥]/;
for (const element of elements) {
const text = normalizeText(element.innerText || element.textContent || '');
if (!labelPattern.test(text)) continue;
const candidates = [];
const afterLabelText = getTextAfterTodayDueLabel(text);
if (afterLabelText) candidates.push(afterLabelText);
const parent = element.parentElement;
if (parent) {
for (const child of Array.from(parent.children || [])) {
if (child === element) continue;
const childText = normalizeText(child.innerText || child.textContent || '');
if (amountPattern.test(childText)) {
candidates.push(childText);
}
}
const parentAfterLabelText = getTextAfterTodayDueLabel(parent.innerText || parent.textContent || '');
if (parentAfterLabelText) candidates.push(parentAfterLabelText);
}
const grandparent = parent?.parentElement;
if (grandparent) {
const grandparentAfterLabelText = getTextAfterTodayDueLabel(grandparent.innerText || grandparent.textContent || '');
if (grandparentAfterLabelText) candidates.push(grandparentAfterLabelText);
}
for (const candidate of candidates) {
const parsed = parseLocalizedAmount(candidate);
if (!parsed) continue;
return {
paymentMethod,
paymentLabel: config.paymentLabel,
checkoutUrlPrefix: config.checkoutUrlPrefix,
checkoutPayload: {
...PLUS_CHECKOUT_BASE_PAYLOAD,
billing_details: {
country: config.billing_details.country,
currency: config.billing_details.currency,
},
},
hasTodayDue: true,
amount: parsed.amount,
isZero: Math.abs(parsed.amount) < 0.005,
rawAmount: parsed.raw,
labelText: text.slice(0, 160),
};
}
return {
hasTodayDue: true,
amount: null,
isZero: false,
rawAmount: '',
labelText: text.slice(0, 160),
};
}
return {
hasTodayDue: false,
amount: null,
isZero: false,
rawAmount: '',
labelText: '',
};
}
@@ -152,9 +259,14 @@ function getActionText(el) {
el?.textContent,
el?.value,
el?.getAttribute?.('aria-label'),
el?.getAttribute?.('aria-labelledby'),
el?.getAttribute?.('title'),
el?.getAttribute?.('placeholder'),
el?.getAttribute?.('name'),
el?.getAttribute?.('autocomplete'),
el?.getAttribute?.('data-elements-stable-field-name'),
el?.getAttribute?.('data-field'),
el?.getAttribute?.('data-field-name'),
el?.id,
].filter(Boolean).join(' '));
}
@@ -208,9 +320,9 @@ function getVisibleControls(selector) {
function findClickableByText(patterns) {
const normalizedPatterns = (Array.isArray(patterns) ? patterns : [patterns])
.filter(Boolean);
const candidates = getVisibleControls('button, a, [role="button"], input[type="button"], input[type="submit"], [tabindex]');
const candidates = getVisibleControls('button, a, [role="button"], [role="tab"], input[type="button"], input[type="submit"], [tabindex]');
return candidates.find((el) => {
const text = getActionText(el);
const text = getCombinedSearchText(el);
return normalizedPatterns.some((pattern) => pattern.test(text));
}) || null;
}
@@ -284,7 +396,7 @@ function findInteractiveAncestor(el) {
let current = el;
for (let depth = 0; current && depth < 8; depth += 1, current = current.parentElement) {
if (!isVisibleElement(current) || isDocumentLevelContainer(current)) continue;
if (current.matches?.('button, a, label, [role="button"], [role="radio"], input[type="radio"], [tabindex]')) {
if (current.matches?.('button, a, label, [role="button"], [role="radio"], [role="tab"], input[type="radio"], [tabindex]')) {
return current;
}
}
@@ -304,6 +416,16 @@ function findPaymentCardAncestor(el, pattern) {
return null;
}
function normalizePlusPaymentMethod(value = '') {
return String(value || '').trim().toLowerCase() === PLUS_PAYMENT_METHOD_GOPAY
? PLUS_PAYMENT_METHOD_GOPAY
: PLUS_PAYMENT_METHOD_PAYPAL;
}
function getPaymentMethodConfig(method = PLUS_PAYMENT_METHOD_PAYPAL) {
return PAYMENT_METHOD_CONFIGS[normalizePlusPaymentMethod(method)] || PAYMENT_METHOD_CONFIGS[PLUS_PAYMENT_METHOD_PAYPAL];
}
function getAncestorChainSummary(el, limit = 6) {
const chain = [];
let current = el;
@@ -326,13 +448,15 @@ function getAncestorChainSummary(el, limit = 6) {
return chain;
}
function getPayPalSearchCandidates() {
function getPaymentMethodSearchCandidates(method = PLUS_PAYMENT_METHOD_PAYPAL) {
const config = getPaymentMethodConfig(method);
const selector = [
'button',
'a',
'label',
'[role="button"]',
'[role="radio"]',
'[role="tab"]',
'input[type="radio"]',
'[tabindex]',
'[data-testid]',
@@ -345,7 +469,10 @@ function getPayPalSearchCandidates() {
].join(', ');
return getVisibleControls(selector)
.filter((el) => /paypal/i.test(getCombinedSearchText(el)))
.filter((el) => {
const text = getCombinedSearchText(el);
return config.patterns.some((pattern) => pattern.test(text));
})
.sort((left, right) => {
const leftRect = left.getBoundingClientRect();
const rightRect = right.getBoundingClientRect();
@@ -353,26 +480,36 @@ function getPayPalSearchCandidates() {
});
}
function findPayPalPaymentMethodTarget() {
const paypalPattern = /paypal/i;
const directClickable = findClickableByText([paypalPattern]);
function getPayPalSearchCandidates() {
return getPaymentMethodSearchCandidates(PLUS_PAYMENT_METHOD_PAYPAL);
}
function getGoPaySearchCandidates() {
return getPaymentMethodSearchCandidates(PLUS_PAYMENT_METHOD_GOPAY);
}
function findPaymentMethodTarget(method = PLUS_PAYMENT_METHOD_PAYPAL) {
const config = getPaymentMethodConfig(method);
const directClickable = findClickableByText(config.patterns);
if (directClickable) {
return directClickable;
}
const radios = getVisibleControls('input[type="radio"], [role="radio"]');
const paypalRadio = radios.find((el) => paypalPattern.test(getCombinedSearchText(el)));
if (paypalRadio) {
return paypalRadio;
const matchedRadio = radios.find((el) => config.patterns.some((pattern) => pattern.test(getCombinedSearchText(el))));
if (matchedRadio) {
return matchedRadio;
}
const candidates = getPayPalSearchCandidates();
const candidates = getPaymentMethodSearchCandidates(method);
for (const candidate of candidates) {
const interactive = findInteractiveAncestor(candidate);
if (interactive && paypalPattern.test(getCombinedSearchText(interactive))) {
if (interactive && config.patterns.some((pattern) => pattern.test(getCombinedSearchText(interactive)))) {
return interactive;
}
const card = findPaymentCardAncestor(candidate, paypalPattern);
const card = config.patterns
.map((pattern) => findPaymentCardAncestor(candidate, pattern))
.find(Boolean);
if (card) {
return card;
}
@@ -381,6 +518,14 @@ function findPayPalPaymentMethodTarget() {
return null;
}
function findPayPalPaymentMethodTarget() {
return findPaymentMethodTarget(PLUS_PAYMENT_METHOD_PAYPAL);
}
function findGoPayPaymentMethodTarget() {
return findPaymentMethodTarget(PLUS_PAYMENT_METHOD_GOPAY);
}
function summarizeElementForDebug(el) {
if (!el) return null;
const rect = el.getBoundingClientRect();
@@ -393,16 +538,24 @@ function summarizeElementForDebug(el) {
};
}
function getPayPalCandidateSummaries(limit = 6) {
return getPayPalSearchCandidates()
function getPaymentMethodCandidateSummaries(method = PLUS_PAYMENT_METHOD_PAYPAL, limit = 6) {
return getPaymentMethodSearchCandidates(method)
.map(summarizeElementForDebug)
.filter(Boolean)
.slice(0, limit);
}
function getPayPalCandidateSummaries(limit = 6) {
return getPaymentMethodCandidateSummaries(PLUS_PAYMENT_METHOD_PAYPAL, limit);
}
function getGoPayCandidateSummaries(limit = 6) {
return getPaymentMethodCandidateSummaries(PLUS_PAYMENT_METHOD_GOPAY, limit);
}
function getPaymentTextPreview(limit = 10) {
const seen = new Set();
const pattern = /paypal|card|payment|billing|subscribe|pay|银行卡|付款|支付|账单|订阅/i;
const pattern = /gopay|go\s*pay|paypal|card|payment|billing|subscribe|pay|银行卡|付款|支付|账单|订阅/i;
return getVisibleControls('button, a, label, [role="button"], [role="radio"], input[type="radio"], input[type="button"], input[type="submit"], [data-testid]')
.map((el) => getCombinedSearchText(el))
.filter((text) => text && pattern.test(text))
@@ -416,28 +569,68 @@ function getPaymentTextPreview(limit = 10) {
}
function getPayPalDiagnostics(reason = '') {
return getPaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_PAYPAL, reason);
}
function getGoPayDiagnostics(reason = '') {
return getPaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_GOPAY, reason);
}
function getPaymentMethodDiagnostics(method = PLUS_PAYMENT_METHOD_PAYPAL, reason = '') {
const config = getPaymentMethodConfig(method);
return {
reason,
url: location.href,
readyState: document.readyState,
paymentMethod: config.id,
paymentMethodLabel: config.label,
paymentCandidates: getPaymentMethodCandidateSummaries(config.id),
paypalCandidates: getPayPalCandidateSummaries(),
gopayCandidates: getGoPayCandidateSummaries(),
paymentTextPreview: getPaymentTextPreview(),
cardFieldsVisible: hasCreditCardFields(),
billingFieldsVisible: hasBillingAddressFields(),
};
}
function writePayPalDiagnostics(reason, level = 'info') {
const diagnostics = getPayPalDiagnostics(reason);
function writePaymentMethodDiagnostics(method = PLUS_PAYMENT_METHOD_PAYPAL, reason = '', level = 'info') {
const config = getPaymentMethodConfig(method);
const diagnostics = getPaymentMethodDiagnostics(config.id, reason);
const writer = typeof console[level] === 'function' ? console[level] : console.info;
writer.call(console, '[MultiPage:plus-checkout] PayPal diagnostics', diagnostics);
log(`Plus Checkout${reason}PayPal 候选 ${diagnostics.paypalCandidates.length} 个,银行卡字段${diagnostics.cardFieldsVisible ? '仍可见' : '不可见'}`, level === 'error' ? 'error' : 'warn');
writer.call(console, `[MultiPage:plus-checkout] ${config.diagnosticLabel} diagnostics`, diagnostics);
log(`Plus Checkout${reason}${config.label} 候选 ${diagnostics.paymentCandidates.length} 个,银行卡字段${diagnostics.cardFieldsVisible ? '仍可见' : '不可见'}`, level === 'error' ? 'error' : 'warn');
return diagnostics;
}
async function createPlusCheckoutSession(payload = {}) {
function writePayPalDiagnostics(reason, level = 'info') {
return writePaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_PAYPAL, reason, level);
}
function writeGoPayDiagnostics(reason, level = 'info') {
return writePaymentMethodDiagnostics(PLUS_PAYMENT_METHOD_GOPAY, reason, level);
}
function buildPlusCheckoutPayload(paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
const config = getPaymentMethodConfig(paymentMethod);
return {
...JSON.parse(JSON.stringify(PLUS_CHECKOUT_PAYLOAD_BASE)),
billing_details: {
...config.billingDetails,
},
};
}
function buildPlusCheckoutUrl(checkoutSessionId, paymentMethod = PLUS_PAYMENT_METHOD_PAYPAL) {
const sessionId = String(checkoutSessionId || '').trim();
if (!sessionId) {
throw new Error('创建 Plus Checkout 失败:未返回 checkout_session_id。');
}
const config = getPaymentMethodConfig(paymentMethod);
return `https://chatgpt.com/checkout/${config.checkoutMerchantPath}/${sessionId}`;
}
async function createPlusCheckoutSession(options = {}) {
await waitForDocumentComplete();
const checkoutConfig = buildPlusCheckoutConfig(payload);
log('Plus:正在读取 ChatGPT 登录会话...');
const sessionResponse = await fetch('/api/auth/session', {
@@ -450,6 +643,8 @@ async function createPlusCheckoutSession(payload = {}) {
}
log('Plus:正在创建 checkout 会话...');
const paymentMethod = normalizePlusPaymentMethod(options.paymentMethod);
const checkoutPayload = buildPlusCheckoutPayload(paymentMethod);
const response = await fetch('https://chatgpt.com/backend-api/payments/checkout', {
method: 'POST',
credentials: 'include',
@@ -457,7 +652,7 @@ async function createPlusCheckoutSession(payload = {}) {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(checkoutConfig.checkoutPayload),
body: JSON.stringify(checkoutPayload),
});
const data = await response.json().catch(() => ({}));
@@ -467,17 +662,17 @@ async function createPlusCheckoutSession(payload = {}) {
}
return {
checkoutUrl: `${checkoutConfig.checkoutUrlPrefix}${data.checkout_session_id}`,
country: checkoutConfig.checkoutPayload.billing_details.country,
currency: checkoutConfig.checkoutPayload.billing_details.currency,
paymentMethod: checkoutConfig.paymentMethod,
checkoutUrl: buildPlusCheckoutUrl(data.checkout_session_id, paymentMethod),
country: checkoutPayload.billing_details.country,
currency: checkoutPayload.billing_details.currency,
};
}
async function selectPayPalPaymentMethod() {
async function selectPaymentMethod(method = PLUS_PAYMENT_METHOD_PAYPAL) {
const config = getPaymentMethodConfig(method);
let lastDiagnosticsAt = 0;
const target = await waitUntil(() => {
const currentTarget = findPayPalPaymentMethodTarget();
const currentTarget = findPaymentMethodTarget(config.id);
if (currentTarget) {
return currentTarget;
}
@@ -485,31 +680,49 @@ async function selectPayPalPaymentMethod() {
const now = Date.now();
if (!lastDiagnosticsAt || now - lastDiagnosticsAt >= PAYPAL_DIAGNOSTIC_LOG_INTERVAL_MS) {
lastDiagnosticsAt = now;
writePayPalDiagnostics('正在等待可点击的 PayPal 付款方式', 'warn');
writePaymentMethodDiagnostics(config.id, `正在等待可点击的 ${config.label} 付款方式`, 'warn');
}
return null;
}, {
label: 'PayPal 付款方式',
label: `${config.label} 付款方式`,
intervalMs: 250,
});
console.info('[MultiPage:plus-checkout] PayPal target selected', summarizeElementForDebug(target));
console.info(`[MultiPage:plus-checkout] ${config.label} target selected`, summarizeElementForDebug(target));
simulateClick(target);
log('Plus Checkout:已点击 PayPal 付款方式,正在确认选中状态。');
log(`Plus Checkout:已点击 ${config.label} 付款方式,正在确认选中状态。`);
if (!await waitForPayPalPaymentMethodActive()) {
const diagnostics = writePayPalDiagnostics('点击 PayPal 后页面仍未进入 PayPal 账单表单', 'error');
throw new Error(`Plus Checkout:已尝试点击 PayPal,但页面未切换到 PayPal 表单。请提供控制台 PayPal diagnostics 结构。候选数量:${diagnostics.paypalCandidates.length},银行卡字段仍可见:${diagnostics.cardFieldsVisible ? '是' : '否'}`);
if (!await waitForPaymentMethodActive(config.id)) {
const diagnostics = writePaymentMethodDiagnostics(config.id, `点击 ${config.label} 后页面仍未进入 ${config.label} 账单表单`, 'error');
throw new Error(`Plus Checkout:已尝试点击 ${config.label},但页面未切换到 ${config.label} 表单。请提供控制台 ${config.label} diagnostics 结构。候选数量:${diagnostics.paymentCandidates.length},银行卡字段仍可见:${diagnostics.cardFieldsVisible ? '是' : '否'}`);
}
log('Plus Checkout:已确认 PayPal 付款方式生效。');
log(`Plus Checkout:已确认 ${config.label} 付款方式生效。`);
return true;
}
async function selectPayPalPaymentMethod() {
return selectPaymentMethod(PLUS_PAYMENT_METHOD_PAYPAL);
}
async function selectGoPayPaymentMethod() {
return selectPaymentMethod(PLUS_PAYMENT_METHOD_GOPAY);
}
async function selectPlusPayPalPaymentMethod() {
await waitForDocumentComplete();
await selectPayPalPaymentMethod();
await selectPaymentMethod(PLUS_PAYMENT_METHOD_PAYPAL);
return {
paymentSelected: true,
paymentMethod: PLUS_PAYMENT_METHOD_PAYPAL,
};
}
async function selectPlusGoPayPaymentMethod() {
await waitForDocumentComplete();
await selectPaymentMethod(PLUS_PAYMENT_METHOD_GOPAY);
return {
paymentSelected: true,
paymentMethod: PLUS_PAYMENT_METHOD_GOPAY,
};
}
@@ -577,22 +790,34 @@ function hasBillingAddressFields() {
});
}
function hasSelectedPayPalControl() {
const paypalPattern = /paypal/i;
const candidates = getPayPalSearchCandidates();
function hasPaymentMethodSelectionMarker(el) {
if (!el) return false;
const className = typeof el.className === 'string' ? el.className : el.getAttribute?.('class') || '';
return el.checked === true
|| el.getAttribute?.('aria-checked') === 'true'
|| el.getAttribute?.('aria-selected') === 'true'
|| el.getAttribute?.('data-state') === 'checked'
|| el.getAttribute?.('data-selected') === 'true'
|| /\b(selected|checked|active)\b/i.test(className);
}
function hasSelectedPaymentMethodControl(method = PLUS_PAYMENT_METHOD_PAYPAL) {
const config = getPaymentMethodConfig(method);
const candidates = getPaymentMethodSearchCandidates(config.id);
return candidates.some((candidate) => {
let current = candidate;
for (let depth = 0; current && depth < 6; depth += 1, current = current.parentElement) {
if (isDocumentLevelContainer(current)) break;
if (!paypalPattern.test(getCombinedSearchText(current))) continue;
const className = typeof current.className === 'string' ? current.className : current.getAttribute?.('class') || '';
const currentMatchesPayment = config.patterns.some((pattern) => pattern.test(getCombinedSearchText(current)));
if (currentMatchesPayment && hasPaymentMethodSelectionMarker(current)) {
return true;
}
const radio = current.querySelector?.('input[type="radio"], [role="radio"]');
if (
current.checked === true
|| current.getAttribute?.('aria-checked') === 'true'
|| current.getAttribute?.('aria-selected') === 'true'
|| current.getAttribute?.('data-state') === 'checked'
|| current.getAttribute?.('data-selected') === 'true'
|| /\b(selected|checked|active)\b/i.test(className)
radio
&& config.patterns.some((pattern) => pattern.test(getCombinedSearchText(current) || getCombinedSearchText(radio)))
&& hasPaymentMethodSelectionMarker(radio)
) {
return true;
}
@@ -601,15 +826,31 @@ function hasSelectedPayPalControl() {
});
}
function isPayPalPaymentMethodActive() {
return hasSelectedPayPalControl();
function hasSelectedPayPalControl() {
return hasSelectedPaymentMethodControl(PLUS_PAYMENT_METHOD_PAYPAL);
}
async function waitForPayPalPaymentMethodActive(timeoutMs = 5000) {
function hasSelectedGoPayControl() {
return hasSelectedPaymentMethodControl(PLUS_PAYMENT_METHOD_GOPAY);
}
function isPaymentMethodActive(method = PLUS_PAYMENT_METHOD_PAYPAL) {
return hasSelectedPaymentMethodControl(method);
}
function isPayPalPaymentMethodActive() {
return isPaymentMethodActive(PLUS_PAYMENT_METHOD_PAYPAL);
}
function isGoPayPaymentMethodActive() {
return isPaymentMethodActive(PLUS_PAYMENT_METHOD_GOPAY);
}
async function waitForPaymentMethodActive(method = PLUS_PAYMENT_METHOD_PAYPAL, timeoutMs = 5000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
throwIfStopped();
if (isPayPalPaymentMethodActive()) {
if (isPaymentMethodActive(method)) {
return true;
}
await sleep(250);
@@ -617,6 +858,10 @@ async function waitForPayPalPaymentMethodActive(timeoutMs = 5000) {
return false;
}
async function waitForPayPalPaymentMethodActive(timeoutMs = 5000) {
return waitForPaymentMethodActive(PLUS_PAYMENT_METHOD_PAYPAL, timeoutMs);
}
async function findAddressSearchInput() {
return waitUntil(() => {
const direct = findInputByFieldText([
@@ -670,6 +915,7 @@ async function clickAddressSuggestion(seed = {}) {
}, {
label: '地址推荐列表',
intervalMs: 250,
timeoutMs: 6000,
});
const suggestionIndex = Math.max(0, Math.min(
@@ -736,6 +982,7 @@ function getCountryCandidates(value = '') {
FR: ['France', '法国'],
GB: ['United Kingdom', 'UK', 'Britain', 'England', '英国'],
HK: ['Hong Kong', '香港'],
ID: ['Indonesia', '印度尼西亚', '印尼'],
IT: ['Italy', '意大利'],
JP: ['Japan', '日本', '日本国'],
KR: ['Korea', 'South Korea', '韩国'],
@@ -750,6 +997,10 @@ function getCountryCandidates(value = '') {
US: ['United States', 'United States of America', 'USA', '美国'],
VN: ['Vietnam', '越南'],
};
const indonesiaCandidates = aliases.ID || [];
if (compact === 'id' || compact === 'indonesia' || compact === '印度尼西亚' || compact === '印尼') {
return Array.from(new Set([raw, 'ID', ...indonesiaCandidates].filter(Boolean)));
}
const direct = aliases[String(raw || '').trim().toUpperCase()] || [];
const matched = Object.entries(aliases).find(([code, names]) => {
if (String(code).toLowerCase() === compact) return true;
@@ -820,7 +1071,7 @@ function findRegionDropdown() {
if (!isEnabledControl(control) || isDocumentLevelContainer(control)) return false;
const text = getFieldText(control);
if (/country/i.test(text) || /\u56fd\u5bb6|\u5730\u533a/.test(text)) return false;
return /state|province|county/i.test(text)
return /state|province|county|prefecture|administrative|administrative[_-]?area/i.test(text)
|| /(?:^|\s)region(?:\s|$)/i.test(text)
|| /\u5dde|\u7701|\u8f96\u533a|\u90fd\u9053\u5e9c\u53bf/.test(text);
}) || null;
@@ -956,24 +1207,24 @@ async function selectCountryDropdown(countryDropdown, value) {
function getStructuredAddressFields() {
const address1 = findInputByFieldText([
/address\s*(?:line)?\s*1|street/i,
/地址\s*1|街道|详细地址/i,
/address\s*(?:line)?\s*1|address[_-]?line[_-]?1|address\[(?:address_)?line1\]|line\s*1|street|street[_-]?address/i,
/地址\s*1|街道|详细地址|住所/i,
]);
const address2 = findInputByFieldText([
/address\s*(?:line)?\s*2|apt|suite|unit/i,
/address\s*(?:line)?\s*2|address[_-]?line[_-]?2|address\[(?:address_)?line2\]|line\s*2|apt|suite|unit/i,
/地址\s*2|公寓|单元|门牌/i,
]);
const city = findInputByFieldText([
/city|town|suburb/i,
/城市|市区/i,
/city|town|suburb|locality|address[_-]?level[_-]?2|address\[city\]/i,
/城市|市区|区市町村|市区町村|市町村/i,
]);
const region = findInputByFieldText([
/state|province|region|county/i,
/省|州|地区/i,
/state|province|region|county|prefecture|administrative|administrative[_-]?area|address[_-]?level[_-]?1|address\[state\]/i,
/省|州|地区|辖区|都道府县|都道府県/i,
]);
const postalCode = findInputByFieldText([
/postal|zip|postcode/i,
/邮编|邮政/i,
/postal|zip|postcode|postal[_-]?code|zip[_-]?code|address\[postal_code\]/i,
/邮编|邮政|郵便番号/i,
]);
return { address1, address2, city, region, postalCode };
}
@@ -1012,6 +1263,7 @@ async function ensureStructuredAddress(seed, options = {}) {
}, {
label: '结构化账单地址字段',
intervalMs: 250,
timeoutMs: 6000,
});
fillIfEmpty(fields.address1, fallback.address1, { overwrite });
@@ -1038,15 +1290,115 @@ async function ensureStructuredAddress(seed, options = {}) {
}
function findSubscribeButton() {
const submitButtons = getVisibleControls('button[type="submit"], input[type="submit"]');
const exactSubmit = submitButtons.find((button) => (
isEnabledControl(button)
&& /订阅|subscribe|购买\s*ChatGPT\s*Plus|start\s*subscription|place\s*order/i.test(getCombinedSearchText(button))
));
if (exactSubmit) {
return exactSubmit;
}
return findClickableByText([
/订阅|继续|确认|支付/i,
/subscribe|continue|confirm|pay|start\s*subscription|place\s*order/i,
]);
}
function isBusySubscribeButton(button) {
if (!button) return true;
const text = getActionText(button);
return button.disabled
|| button.getAttribute?.('aria-disabled') === 'true'
|| button.getAttribute?.('aria-busy') === 'true'
|| button.closest?.('[aria-busy="true"], [data-loading="true"], [data-state="loading"]')
|| /loading|processing|submitting|请稍候|处理中|加载中/i.test(text);
}
function getAssociatedForm(button) {
if (!button) return null;
if (button.form) return button.form;
const formId = String(button.getAttribute?.('form') || '').trim();
if (formId) {
return document.getElementById(formId) || null;
}
return button.closest?.('form') || null;
}
async function humanLikeClick(el) {
throwIfStopped();
if (!el) {
throw new Error('无法点击空元素。');
}
el.scrollIntoView?.({ block: 'center', inline: 'center', behavior: 'instant' });
await sleep(300);
if (typeof el.focus === 'function') {
el.focus({ preventScroll: true });
await sleep(150);
}
const rect = el.getBoundingClientRect();
const clientX = Math.floor(rect.left + rect.width / 2);
const clientY = Math.floor(rect.top + rect.height / 2);
const eventInit = {
bubbles: true,
cancelable: true,
composed: true,
view: window,
clientX,
clientY,
button: 0,
buttons: 1,
};
const pointerCtor = typeof PointerEvent === 'function' ? PointerEvent : MouseEvent;
const events = [
['pointerover', pointerCtor],
['pointerenter', pointerCtor],
['mouseover', MouseEvent],
['mouseenter', MouseEvent],
['pointermove', pointerCtor],
['mousemove', MouseEvent],
['pointerdown', pointerCtor],
['mousedown', MouseEvent],
['pointerup', pointerCtor],
['mouseup', MouseEvent],
['click', MouseEvent],
];
for (const [type, EventCtor] of events) {
throwIfStopped();
el.dispatchEvent(new EventCtor(type, eventInit));
await sleep(type === 'mousedown' || type === 'pointerdown' ? 120 : 30);
}
if (typeof el.click === 'function') {
await sleep(120);
el.click();
}
const type = String(el.getAttribute?.('type') || el.type || '').trim().toLowerCase();
const form = getAssociatedForm(el);
if (
form
&& typeof form.requestSubmit === 'function'
&& (
(String(el.tagName || '').toUpperCase() === 'BUTTON' && (!type || type === 'submit'))
|| (String(el.tagName || '').toUpperCase() === 'INPUT' && type === 'submit')
)
) {
await sleep(250);
form.requestSubmit(el);
}
console.log('[MultiPage:plus-checkout] 已执行拟人工点击', summarizeElementForDebug(el));
log(`已拟人工点击 [${el.tagName}] "${el.textContent?.trim().slice(0, 30) || ''}"`);
}
async function fillPlusBillingAndSubmit(payload = {}) {
await waitForDocumentComplete();
await selectPayPalPaymentMethod();
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
await selectPaymentMethod(paymentMethod);
const billingResult = await fillPlusBillingAddress(payload);
if (payload.skipSubmit) {
@@ -1117,40 +1469,63 @@ async function selectPlusAddressSuggestion(payload = {}) {
async function ensurePlusStructuredBillingAddress(payload = {}) {
await waitForDocumentComplete();
const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {});
const structuredAddress = await ensureStructuredAddress(payload.addressSeed || {}, {
overwrite: Boolean(payload.overwriteStructuredAddress),
});
return {
countryText: readCountryText(),
structuredAddress,
};
}
async function clickPlusSubscribe() {
async function clickPlusSubscribe(payload = {}) {
const paymentMethod = normalizePlusPaymentMethod(payload.paymentMethod);
if ((payload.ensurePayPalActive || payload.ensurePaymentActive) && !isPaymentMethodActive(paymentMethod)) {
await selectPaymentMethod(paymentMethod);
}
const subscribeButton = await waitUntil(() => {
const button = findSubscribeButton();
return button && isEnabledControl(button) ? button : null;
return button && isEnabledControl(button) && !isBusySubscribeButton(button) ? button : null;
}, {
label: '订阅按钮',
intervalMs: 250,
timeoutMs: 10000,
});
simulateClick(subscribeButton);
await sleep(Math.max(0, Math.floor(Number(payload.beforeClickDelayMs) || 0)));
await humanLikeClick(subscribeButton);
return {
clicked: true,
};
}
function inspectPlusCheckoutState() {
const structuredAddress = getStructuredAddressFields();
async function readChatGptSessionAccessToken() {
const sessionResponse = await fetch('/api/auth/session', {
credentials: 'include',
});
const session = await sessionResponse.json().catch(() => ({}));
return {
session,
accessToken: String(session?.accessToken || '').trim(),
};
}
async function inspectPlusCheckoutState(options = {}) {
const structuredAddress = getStructuredAddressFields();
const state = {
url: location.href,
readyState: document.readyState,
countryText: readCountryText(),
hasPayPal: Boolean(findPayPalPaymentMethodTarget()),
hasGoPay: Boolean(findGoPayPaymentMethodTarget()),
paypalCandidates: getPayPalCandidateSummaries(),
gopayCandidates: getGoPayCandidateSummaries(),
paymentTextPreview: getPaymentTextPreview(),
cardFieldsVisible: hasCreditCardFields(),
billingFieldsVisible: hasBillingAddressFields(),
hasSubscribeButton: Boolean(findSubscribeButton()),
checkoutAmountSummary: getCheckoutAmountSummary(),
addressFieldValues: {
address1: structuredAddress.address1?.value || '',
city: structuredAddress.city?.value || '',
@@ -1158,5 +1533,11 @@ function inspectPlusCheckoutState() {
postalCode: structuredAddress.postalCode?.value || '',
},
};
if (options.includeSession || options.includeAccessToken) {
const sessionState = await readChatGptSessionAccessToken();
state.session = sessionState.session;
state.accessToken = sessionState.accessToken;
}
return state;
}
})();
+2611 -93
View File
File diff suppressed because it is too large Load Diff
+79 -18
View File
@@ -24,7 +24,7 @@ if (document.documentElement.getAttribute(SUB2API_PANEL_LISTENER_SENTINEL) !== '
}).catch((err) => {
if (isStopError(err)) {
if (message.step) {
log(`步骤 ${message.step}已被用户停止。`, 'warn');
log('已被用户停止。', 'warn', { step: message.step });
}
sendResponse({ stopped: true, error: err.message });
return;
@@ -191,6 +191,53 @@ async function getGroupByName(origin, token, groupName) {
return group;
}
function normalizeSub2ApiGroupNames(value) {
const source = Array.isArray(value)
? value
: String(value || '').split(/[\r\n,;]+/);
const seen = new Set();
const names = [];
for (const item of source) {
const name = String(item || '').trim();
const key = name.toLowerCase();
if (!name || seen.has(key)) continue;
seen.add(key);
names.push(name);
}
return names.length ? names : [SUB2API_DEFAULT_GROUP_NAME];
}
async function getGroupsByNames(origin, token, groupNames) {
const targetNames = normalizeSub2ApiGroupNames(groupNames);
const groups = await requestJson(origin, '/api/v1/admin/groups/all', {
method: 'GET',
token,
});
const matched = [];
const missing = [];
for (const targetName of targetNames) {
const normalized = targetName.toLowerCase();
const group = (groups || []).find((item) => {
const itemName = String(item?.name || '').trim().toLowerCase();
if (!itemName) return false;
if (itemName !== normalized) return false;
return !item.platform || item.platform === 'openai';
});
if (group) {
matched.push(group);
} else {
missing.push(targetName);
}
}
if (missing.length) {
throw new Error(`SUB2API 中未找到以下 openai 分组:${missing.join('、')}`);
}
return matched;
}
function normalizeSub2ApiProxyPreference(value) {
return String(value || '').trim();
}
@@ -352,7 +399,7 @@ function extractStateFromAuthUrl(authUrl) {
}
}
function parseLocalhostCallback(rawUrl) {
function parseLocalhostCallback(rawUrl, visibleStep = 10) {
let parsed;
try {
parsed = new URL(rawUrl);
@@ -364,7 +411,7 @@ function parseLocalhostCallback(rawUrl) {
throw new Error('回调 URL 协议不正确。');
}
if (!['localhost', '127.0.0.1'].includes(parsed.hostname)) {
throw new Error('步骤 10 只接受 localhost / 127.0.0.1 回调地址。');
throw new Error(`步骤 ${visibleStep} 只接受 localhost / 127.0.0.1 回调地址。`);
}
if (parsed.pathname !== '/auth/callback') {
throw new Error('回调 URL 路径必须是 /auth/callback。');
@@ -448,16 +495,19 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
const { report = true } = options;
const logStep = Number.isInteger(payload?.logStep) ? payload.logStep : 1;
const redirectUri = normalizeRedirectUri();
const groupName = (payload.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME).trim() || SUB2API_DEFAULT_GROUP_NAME;
const groupNames = normalizeSub2ApiGroupNames(payload.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
const groupName = groupNames[0] || SUB2API_DEFAULT_GROUP_NAME;
const { origin, token } = await loginSub2Api(payload);
const group = await getGroupByName(origin, token, groupName);
const groups = await getGroupsByNames(origin, token, groupNames);
const group = groups[0];
const proxyPreference = resolveSub2ApiProxyPreference(payload);
const proxy = proxyPreference ? await resolveSub2ApiProxy(origin, token, proxyPreference) : null;
const proxyId = normalizeProxyId(proxy?.id);
const draftName = buildDraftAccountName(group.name || groupName);
const groupLabel = groups.map((item) => `${item.name}#${item.id}`).join('、');
log(`步骤 ${logStep}:已登录 SUB2API,使用分组 ${group.name}#${group.id}`);
log(`步骤 ${logStep}:已登录 SUB2API,使用分组 ${groupLabel}`);
if (proxy) {
log(`步骤 ${logStep}:已选择 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}`);
} else {
@@ -492,6 +542,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
sub2apiSessionId: sessionId,
sub2apiOAuthState: oauthState,
sub2apiGroupId: group.id,
sub2apiGroupIds: groups.map((item) => item.id),
sub2apiDraftName: draftName,
sub2apiProxyId: proxyId,
};
@@ -504,7 +555,7 @@ async function step1_generateOpenAiAuthUrl(payload = {}, options = {}) {
async function step9_submitOpenAiCallback(payload = {}) {
const visibleStep = Number(payload?.visibleStep) || 10;
const callback = parseLocalhostCallback(payload.localhostUrl || '');
const callback = parseLocalhostCallback(payload.localhostUrl || '', visibleStep);
const backgroundState = await getBackgroundState();
const flowEmail = String(backgroundState.email || '').trim();
@@ -517,9 +568,17 @@ async function step9_submitOpenAiCallback(payload = {}) {
const proxySelector = preferredProxyId || proxyPreference;
const proxy = proxySelector ? await resolveSub2ApiProxy(origin, token, proxySelector) : null;
const proxyId = normalizeProxyId(proxy?.id);
const group = payload.sub2apiGroupId
? { id: payload.sub2apiGroupId, name: payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME }
: await getGroupByName(origin, token, payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME);
const storedGroupIds = Array.isArray(payload.sub2apiGroupIds)
? payload.sub2apiGroupIds
: (Array.isArray(backgroundState.sub2apiGroupIds) ? backgroundState.sub2apiGroupIds : []);
const groupIdsFromState = storedGroupIds
.map((id) => Number(id))
.filter((id) => Number.isFinite(id) && id > 0);
const groups = groupIdsFromState.length
? groupIdsFromState.map((id) => ({ id }))
: (payload.sub2apiGroupId
? [{ id: payload.sub2apiGroupId, name: payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME }]
: await getGroupsByNames(origin, token, payload.sub2apiGroupName || backgroundState.sub2apiGroupName || SUB2API_DEFAULT_GROUP_NAME));
if (!sessionId) {
throw new Error('缺少 SUB2API session_id,请重新执行步骤 1。');
@@ -528,11 +587,11 @@ async function step9_submitOpenAiCallback(payload = {}) {
throw new Error('本次 localhost 回调中的 state 与步骤 1 生成的 state 不一致,请重新执行步骤 1。');
}
log(`步骤 ${visibleStep}正在向 SUB2API 交换 OpenAI 授权码...`);
log('正在向 SUB2API 交换 OpenAI 授权码...', 'info', { step: visibleStep, stepKey: 'platform-verify' });
if (proxy) {
log(`步骤 ${visibleStep}使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}`);
log(`使用 SUB2API 默认代理 ${buildProxyDisplayName(proxy)}`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
} else {
log(`步骤 ${visibleStep}未配置 SUB2API 默认代理,本次将不使用代理。`);
log('未配置 SUB2API 默认代理,本次将不使用代理。', 'info', { step: visibleStep, stepKey: 'platform-verify' });
}
const exchangeRequestBody = {
session_id: sessionId,
@@ -551,8 +610,10 @@ async function step9_submitOpenAiCallback(payload = {}) {
const credentials = buildOpenAiCredentials(exchangeData);
const extra = buildOpenAiExtra(exchangeData);
const resolvedEmail = String(exchangeData?.email || credentials?.email || '').trim();
const groupId = Number(group.id);
if (!Number.isFinite(groupId) || groupId <= 0) {
const groupIds = groups
.map((group) => Number(group.id))
.filter((id) => Number.isFinite(id) && id > 0);
if (!groupIds.length) {
throw new Error('SUB2API 返回的目标分组 ID 无效。');
}
const accountName = resolvedEmail
@@ -568,7 +629,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
concurrency: SUB2API_DEFAULT_CONCURRENCY,
priority: SUB2API_DEFAULT_PRIORITY,
rate_multiplier: SUB2API_DEFAULT_RATE_MULTIPLIER,
group_ids: [groupId],
group_ids: groupIds,
auto_pause_on_expired: true,
};
if (proxyId) {
@@ -579,7 +640,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
createPayload.extra = extra;
}
log(`步骤 ${visibleStep}授权码交换成功,正在创建 SUB2API 账号(名称:${accountName}...`);
log(`授权码交换成功,正在创建 SUB2API 账号(名称:${accountName}...`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
const createdAccount = await requestJson(origin, '/api/v1/admin/accounts', {
method: 'POST',
token,
@@ -587,7 +648,7 @@ async function step9_submitOpenAiCallback(payload = {}) {
});
const verifiedStatus = `SUB2API 已创建账号 #${createdAccount?.id || 'unknown'}`;
log(`步骤 ${visibleStep}${verifiedStatus}`, 'ok');
log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' });
reportComplete(visibleStep, {
localhostUrl: callback.url,
verifiedStatus,
+18 -5
View File
@@ -262,17 +262,30 @@ function fillSelect(el, value) {
log(`已选择 [${el.name || el.id || '未知'}] = ${value}`);
}
function normalizeLogStep(value) {
const step = Math.floor(Number(value) || 0);
return step > 0 ? step : null;
}
/**
* Send a log message to Side Panel via Background.
* @param {string} message
* @param {string} level - 'info' | 'ok' | 'warn' | 'error'
* @param {{ step?: number, stepKey?: string }} options
*/
function log(message, level = 'info') {
function log(message, level = 'info', options = {}) {
const step = normalizeLogStep(options?.step);
chrome.runtime.sendMessage({
type: 'LOG',
source: getRuntimeScriptSource(),
step: null,
payload: { message, level, timestamp: Date.now() },
step,
payload: {
message: String(message || ''),
level,
timestamp: Date.now(),
step,
stepKey: String(options?.stepKey || '').trim(),
},
error: null,
});
}
@@ -305,7 +318,7 @@ function reportReady() {
*/
function reportComplete(step, data = {}) {
console.log(LOG_PREFIX, `步骤 ${step} 已完成`, data);
log(`步骤 ${step} 已成功完成`, 'ok');
log('已成功完成', 'ok', { step });
const message = {
type: 'STEP_COMPLETE',
source: getRuntimeScriptSource(),
@@ -336,7 +349,7 @@ function reportComplete(step, data = {}) {
*/
function reportError(step, errorMessage) {
console.error(LOG_PREFIX, `步骤 ${step} 失败: ${errorMessage}`);
log(`步骤 ${step} 失败:${errorMessage}`, 'error');
log(`失败:${errorMessage}`, 'error', { step });
const message = {
type: 'STEP_ERROR',
source: getRuntimeScriptSource(),
+20 -17
View File
@@ -66,7 +66,7 @@ if (document.documentElement.getAttribute(VPS_PANEL_LISTENER_SENTINEL) !== '1')
});
if (isStopError(err)) {
if (message.step) {
log(`步骤 ${message.step}已被用户停止。`, 'warn');
log('已被用户停止。', 'warn', { step: message.step });
}
sendResponse({ stopped: true, error: err.message });
return;
@@ -88,6 +88,7 @@ async function handleStep(step, payload) {
case 1: return await step1_getOAuthLink(payload);
case 10:
case 12:
case 13:
return await step9_vpsVerify({ ...(payload || {}), visibleStep: step });
default:
throw new Error(`vps-panel.js 不处理步骤 ${step}`);
@@ -618,7 +619,7 @@ function explainStep10Failure(statusText, sourceKind = 'unknown') {
{
code: 'oauth_state_mismatch',
pattern: /state code error/i,
message: 'CPA 校验到回调里的 state 与当前 OAuth 会话不一致,通常是步骤 1 已刷新过新的授权链接,但步骤 10 仍提交旧回调。',
message: 'CPA 校验到回调里的 state 与当前 OAuth 会话不一致,通常是授权链接已刷新,但平台回调验证仍提交旧回调。',
},
{
code: 'oauth_code_exchange_failed',
@@ -666,7 +667,7 @@ function explainStep10Failure(statusText, sourceKind = 'unknown') {
};
}
async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS) {
async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS, visibleStep = 10) {
const start = Date.now();
let lastDiagnosticsSignature = '';
let lastHeartbeatLoggedAt = 0;
@@ -681,11 +682,11 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
if (diagnostics.signature !== lastDiagnosticsSignature) {
lastDiagnosticsSignature = diagnostics.signature;
lastHeartbeatLoggedAt = elapsed;
log(`步骤 10认证状态检测中,${diagnostics.summary}`);
log(`认证状态检测中,${diagnostics.summary}`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
console.log(LOG_PREFIX, '[Step 9] status badge diagnostics changed', diagnostics);
} else if (elapsed - lastHeartbeatLoggedAt >= 10000) {
lastHeartbeatLoggedAt = elapsed;
log(`步骤 10仍在等待认证成功,${diagnostics.summary}`);
log(`仍在等待认证成功,${diagnostics.summary}`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
console.log(LOG_PREFIX, '[Step 9] still waiting for success badge', diagnostics);
}
@@ -697,8 +698,9 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
if (callbackSubmittedSignature !== lastCallbackSubmittedSignature) {
lastCallbackSubmittedSignature = callbackSubmittedSignature;
log(
`步骤 10CPA 已接受 localhost 回调,正在等待后台完成认证。回调提示=${formatStep10StatusSummaryValue(diagnostics.callbackStatusText)};主状态=${formatStep10StatusSummaryValue(diagnostics.mainStatusText)}`,
'info'
`CPA 已接受 localhost 回调,正在等待后台完成认证。回调提示=${formatStep10StatusSummaryValue(diagnostics.callbackStatusText)};主状态=${formatStep10StatusSummaryValue(diagnostics.mainStatusText)}`,
'info',
{ step: visibleStep, stepKey: 'platform-verify' }
);
console.info(LOG_PREFIX, '[Step 9] callback accepted and waiting for auth completion', diagnostics);
}
@@ -706,7 +708,7 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
if (isStep10BrowserSwitchRequiredConflict(diagnostics)) {
const browserSwitchMessage = getStep10BrowserSwitchRequiredMessage(diagnostics);
log(`步骤 10${browserSwitchMessage}`, 'error');
log(browserSwitchMessage, 'error', { step: visibleStep, stepKey: 'platform-verify' });
console.error(LOG_PREFIX, '[Step 9] browser-switch conflict detected', diagnostics);
throw new Error(`BROWSER_SWITCH_REQUIRED::${browserSwitchMessage}`);
}
@@ -723,8 +725,9 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
? diagnostics.pageErrorSummary
: diagnostics.failureSummary;
log(
`步骤 10同时检测到成功徽标和失败提示,本轮不判定成功。成功徽标:${diagnostics.exactSuccessSummary};失败提示:${failureSummary}`,
'warn'
`同时检测到成功徽标和失败提示,本轮不判定成功。成功徽标:${diagnostics.exactSuccessSummary};失败提示:${failureSummary}`,
'warn',
{ step: visibleStep, stepKey: 'platform-verify' }
);
console.warn(LOG_PREFIX, '[Step 9] success badge is blocked by visible failure', diagnostics);
}
@@ -1021,7 +1024,7 @@ async function step9_vpsVerify(payload) {
throw new Error(`步骤 ${visibleStep} 只接受真实的 localhost OAuth 回调地址,请重新执行步骤 ${confirmStep}`);
}
if (!localhostUrl) {
log(`步骤 ${visibleStep}payload 中没有 localhostUrl,正在从状态中读取...`);
log('payload 中没有 localhostUrl,正在从状态中读取...', 'info', { step: visibleStep, stepKey: 'platform-verify' });
const state = await chrome.runtime.sendMessage({ type: 'GET_STATE' });
localhostUrl = state.localhostUrl;
if (localhostUrl && !isLocalhostOAuthCallbackUrl(localhostUrl)) {
@@ -1031,9 +1034,9 @@ async function step9_vpsVerify(payload) {
if (!localhostUrl) {
throw new Error(`未找到 localhost 回调地址,请先完成步骤 ${confirmStep}`);
}
log(`步骤 ${visibleStep}已获取 localhostUrl${localhostUrl.slice(0, 60)}...`);
log(`已获取 localhostUrl${localhostUrl.slice(0, 60)}...`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
log(`步骤 ${visibleStep}:正在查找回调地址输入框...`);
log('正在查找回调地址输入框...', 'info', { step: visibleStep, stepKey: 'platform-verify' });
// Find the callback URL input
// Actual DOM: <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
@@ -1050,7 +1053,7 @@ async function step9_vpsVerify(payload) {
await humanPause(600, 1500);
fillInput(urlInput, localhostUrl);
log(`步骤 ${visibleStep}已填写回调地址:${localhostUrl.slice(0, 80)}...`);
log(`已填写回调地址:${localhostUrl.slice(0, 80)}...`, 'info', { step: visibleStep, stepKey: 'platform-verify' });
// Find and click the callback submit button in supported UI languages.
const callbackSubmitPattern = /提交回调\s*URL|Submit\s+Callback\s+URL|Отправить\s+Callback\s+URL/i;
@@ -1071,9 +1074,9 @@ async function step9_vpsVerify(payload) {
await humanPause(450, 1200);
simulateClick(submitBtn);
log(`步骤 ${visibleStep}已点击回调提交按钮,正在等待认证结果...`);
log('已点击回调提交按钮,正在等待认证结果...', 'info', { step: visibleStep, stepKey: 'platform-verify' });
const verifiedStatus = await waitForExactSuccessBadge();
log(`步骤 ${visibleStep}${verifiedStatus}`, 'ok');
const verifiedStatus = await waitForExactSuccessBadge(STEP9_SUCCESS_BADGE_TIMEOUT_MS, visibleStep);
log(verifiedStatus, 'ok', { step: visibleStep, stepKey: 'platform-verify' });
reportComplete(visibleStep, { localhostUrl, verifiedStatus });
}
+144
View File
@@ -0,0 +1,144 @@
// content/whatsapp-flow.js — WhatsApp Web code reader for GoPay.
console.log('[MultiPage:whatsapp-flow] Content script loaded on', location.href);
const WHATSAPP_FLOW_LISTENER_SENTINEL = 'data-multipage-whatsapp-flow-listener';
if (document.documentElement.getAttribute(WHATSAPP_FLOW_LISTENER_SENTINEL) !== '1') {
document.documentElement.setAttribute(WHATSAPP_FLOW_LISTENER_SENTINEL, '1');
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (
message.type === 'WHATSAPP_GET_STATE'
|| message.type === 'WHATSAPP_FIND_CODE'
) {
resetStopState();
handleWhatsAppCommand(message).then((result) => {
sendResponse({ ok: true, ...(result || {}) });
}).catch((err) => {
if (isStopError(err)) {
sendResponse({ stopped: true, error: err.message });
return;
}
sendResponse({ error: err.message });
});
return true;
}
});
} else {
console.log('[MultiPage:whatsapp-flow] 消息监听已存在,跳过重复注册');
}
async function handleWhatsAppCommand(message) {
switch (message.type) {
case 'WHATSAPP_GET_STATE':
return inspectWhatsAppState();
case 'WHATSAPP_FIND_CODE':
return findWhatsAppCode(message.payload || {});
default:
throw new Error(`whatsapp-flow.js 不处理消息:${message.type}`);
}
}
function normalizeText(text = '') {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function getBodyText() {
return normalizeText(document.body?.innerText || document.body?.textContent || '');
}
function extractVerificationCode(text = '') {
const normalized = normalizeText(text);
const preferredPatterns = [
/(?:gopay|gojek|openai|chatgpt|kode|code|otp|verification|verifikasi|whatsapp|验证码|驗證碼|代码|代碼)[^\d]{0,40}(\d{4,8})/i,
/(\d{4,8})[^\d]{0,40}(?:gopay|gojek|openai|chatgpt|kode|code|otp|verification|verifikasi|验证码|驗證碼|代码|代碼)/i,
];
for (const pattern of preferredPatterns) {
const match = normalized.match(pattern);
if (match?.[1]) {
return match[1];
}
}
const genericMatch = normalized.match(/(?<!\d)(\d{4,8})(?!\d)/);
return genericMatch?.[1] || '';
}
function getMessageCandidates() {
const selectors = [
'[data-testid*="msg" i]',
'[data-pre-plain-text]',
'.message-in',
'[role="row"]',
'div',
'span',
];
const seen = new Set();
const messages = [];
for (const selector of selectors) {
for (const el of Array.from(document.querySelectorAll(selector))) {
const text = normalizeText(el.innerText || el.textContent || '');
if (!text || text.length < 4 || seen.has(text)) continue;
seen.add(text);
messages.push(text);
}
}
return messages.slice(-80);
}
async function waitUntil(predicate, options = {}) {
const intervalMs = Math.max(50, Math.floor(Number(options.intervalMs) || 1000));
const timeoutMs = Math.max(0, Math.floor(Number(options.timeoutMs) || 0));
const startedAt = Date.now();
while (true) {
throwIfStopped();
const value = await predicate();
if (value) {
return value;
}
if (timeoutMs > 0 && Date.now() - startedAt >= timeoutMs) {
return null;
}
await sleep(intervalMs);
}
}
async function findWhatsAppCode(payload = {}) {
const timeoutMs = Math.max(0, Math.floor(Number(payload.timeoutMs) || 0));
const result = await waitUntil(() => {
const messages = getMessageCandidates();
for (let index = messages.length - 1; index >= 0; index -= 1) {
const text = messages[index];
const code = extractVerificationCode(text);
if (code) {
return {
code,
messageText: text.slice(0, 500),
};
}
}
const code = extractVerificationCode(getBodyText());
return code ? { code, messageText: getBodyText().slice(0, 500) } : null;
}, {
intervalMs: 1000,
timeoutMs,
});
return result || {
code: '',
messageText: '',
};
}
function inspectWhatsAppState() {
const bodyText = getBodyText();
const code = extractVerificationCode(bodyText);
return {
url: location.href,
readyState: document.readyState,
loggedIn: !/use whatsapp on your computer|link a device|scan|qr|使用 WhatsApp|扫码|掃碼/i.test(bodyText),
code,
textPreview: bodyText.slice(0, 500),
messagePreview: getMessageCandidates().slice(-8),
};
}
+46
View File
@@ -5,7 +5,9 @@
AU: ['au', 'aus', 'australia', '澳大利亚'],
DE: ['de', 'deu', 'germany', 'deutschland', '德国'],
FR: ['fr', 'fra', 'france', '法国'],
ID: ['id', 'indonesia', '印度尼西亚', '印尼'],
JP: ['jp', 'jpn', 'japan', '日本', '日本国'],
KR: ['kr', 'kor', 'korea', 'south korea', '韩国', '대한민국'],
US: ['us', 'usa', 'united states', 'united states of america', 'america', '美国'],
};
@@ -76,6 +78,28 @@
},
},
],
ID: [
{
query: 'Jakarta Indonesia',
suggestionIndex: 1,
fallback: {
address1: 'Jalan M.H. Thamrin No. 1',
city: 'Jakarta',
region: 'DKI Jakarta',
postalCode: '10310',
},
},
{
query: 'Jakarta Selatan',
suggestionIndex: 1,
fallback: {
address1: 'Jalan Jenderal Sudirman Kav. 52-53',
city: 'Jakarta',
region: 'DKI Jakarta',
postalCode: '12190',
},
},
],
JP: [
{
query: 'Tokyo Marunouchi',
@@ -98,6 +122,28 @@
},
},
],
KR: [
{
query: 'Seoul Jung-gu',
suggestionIndex: 1,
fallback: {
address1: 'Sejong-daero 110',
city: 'Jung-gu',
region: 'Seoul',
postalCode: '04524',
},
},
{
query: 'Seoul Gangnam-gu',
suggestionIndex: 1,
fallback: {
address1: 'Teheran-ro 152',
city: 'Gangnam-gu',
region: 'Seoul',
postalCode: '06236',
},
},
],
US: [
{
query: 'New York NY',
+82 -9
View File
@@ -1,6 +1,13 @@
(function attachStepDefinitions(root, factory) {
root.MultiPageStepDefinitions = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createStepDefinitionsModule() {
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
const PLUS_PAYMENT_STEP_KEY = 'paypal-approve';
const SIGNUP_METHOD_EMAIL = 'email';
const SIGNUP_METHOD_PHONE = 'phone';
const NORMAL_STEP_DEFINITIONS = [
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' },
{ id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' },
@@ -21,7 +28,7 @@
{ id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' },
{ id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' },
{ id: 6, order: 60, key: 'plus-checkout-create', title: '创建 Plus Checkout' },
{ id: 7, order: 70, key: 'plus-checkout-billing', title: '填写账单并提交订' },
{ id: 7, order: 70, key: 'plus-checkout-billing', title: '填写账单并提交订' },
{ id: 8, order: 80, key: 'paypal-approve', title: 'PayPal 登录与授权' },
{ id: 9, order: 90, key: 'plus-checkout-return', title: '订阅回跳确认' },
{ id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' },
@@ -44,29 +51,89 @@
{ id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' },
];
const PLUS_GPC_STEP_DEFINITIONS = [
{ id: 1, order: 10, key: 'open-chatgpt', title: '打开 ChatGPT 官网' },
{ id: 2, order: 20, key: 'submit-signup-email', title: '注册并输入邮箱' },
{ id: 3, order: 30, key: 'fill-password', title: '填写密码并继续' },
{ id: 4, order: 40, key: 'fetch-signup-code', title: '获取注册验证码' },
{ id: 5, order: 50, key: 'fill-profile', title: '填写姓名和生日' },
{ id: 6, order: 60, key: 'plus-checkout-create', title: '创建 GPC 订单' },
{ id: 7, order: 70, key: 'plus-checkout-billing', title: 'GPC OTP/PIN 验证' },
{ id: 10, order: 100, key: 'oauth-login', title: '刷新 OAuth 并登录' },
{ id: 11, order: 110, key: 'fetch-login-code', title: '获取登录验证码' },
{ id: 12, order: 120, key: 'confirm-oauth', title: '自动确认 OAuth' },
{ id: 13, order: 130, key: 'platform-verify', title: '平台回调验证' },
];
const PHONE_SIGNUP_TITLE_OVERRIDES = Object.freeze({
'submit-signup-email': '注册并输入手机号',
'fetch-signup-code': '获取手机验证码',
});
function isPlusModeEnabled(options = {}) {
return Boolean(options?.plusModeEnabled || options?.plusMode);
}
function normalizePlusPaymentMethod(value = '') {
return String(value || '').trim().toLowerCase() === 'gopay' ? 'gopay' : 'paypal';
const normalized = String(value || '').trim().toLowerCase();
if (normalized === PLUS_PAYMENT_METHOD_GPC_HELPER) {
return PLUS_PAYMENT_METHOD_GPC_HELPER;
}
return normalized === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_PAYMENT_METHOD_GOPAY : PLUS_PAYMENT_METHOD_PAYPAL;
}
function normalizeSignupMethod(value = '') {
return String(value || '').trim().toLowerCase() === SIGNUP_METHOD_PHONE
? SIGNUP_METHOD_PHONE
: SIGNUP_METHOD_EMAIL;
}
function getResolvedSignupMethod(options = {}) {
return normalizeSignupMethod(options?.resolvedSignupMethod || options?.signupMethod);
}
function getModeStepDefinitions(options = {}) {
if (!isPlusModeEnabled(options)) {
return NORMAL_STEP_DEFINITIONS;
}
return normalizePlusPaymentMethod(options?.plusPaymentMethod || options?.paymentMethod) === 'gopay'
? PLUS_GOPAY_STEP_DEFINITIONS
: PLUS_PAYPAL_STEP_DEFINITIONS;
const paymentMethod = normalizePlusPaymentMethod(options?.plusPaymentMethod || options?.paymentMethod);
if (paymentMethod === PLUS_PAYMENT_METHOD_GPC_HELPER) {
return PLUS_GPC_STEP_DEFINITIONS;
}
return paymentMethod === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_GOPAY_STEP_DEFINITIONS : PLUS_PAYPAL_STEP_DEFINITIONS;
}
function cloneSteps(steps = []) {
return steps.map((step) => ({ ...step }));
function getPlusPaymentStepTitle(options = {}) {
if (!isPlusModeEnabled(options)) {
return '';
}
const paymentStep = getModeStepDefinitions({
...options,
plusModeEnabled: true,
}).find((step) => step.key === PLUS_PAYMENT_STEP_KEY);
return paymentStep?.title || '';
}
function getResolvedStepTitle(step = {}, options = {}) {
if (isPlusModeEnabled(options) && step.key === PLUS_PAYMENT_STEP_KEY) {
return getPlusPaymentStepTitle(options) || step.title;
}
const signupMethod = getResolvedSignupMethod(options);
if (signupMethod === SIGNUP_METHOD_PHONE && PHONE_SIGNUP_TITLE_OVERRIDES[step.key]) {
return PHONE_SIGNUP_TITLE_OVERRIDES[step.key];
}
return step.title;
}
function cloneSteps(steps = [], options = {}) {
return steps.map((step) => ({
...step,
title: getResolvedStepTitle(step, options),
}));
}
function getSteps(options = {}) {
return cloneSteps(getModeStepDefinitions(options));
return cloneSteps(getModeStepDefinitions(options), options);
}
function getAllSteps() {
@@ -75,6 +142,7 @@
...NORMAL_STEP_DEFINITIONS,
...PLUS_PAYPAL_STEP_DEFINITIONS,
...PLUS_GOPAY_STEP_DEFINITIONS,
...PLUS_GPC_STEP_DEFINITIONS,
]) {
keyed.set(`${step.id}:${step.key}`, step);
}
@@ -101,7 +169,7 @@
function getStepById(id, options = {}) {
const numericId = Number(id);
const match = getModeStepDefinitions(options).find((step) => step.id === numericId);
return match ? { ...match } : null;
return match ? cloneSteps([match], options)[0] : null;
}
return {
@@ -110,12 +178,17 @@
PLUS_STEP_DEFINITIONS: PLUS_PAYPAL_STEP_DEFINITIONS,
PLUS_PAYPAL_STEP_DEFINITIONS,
PLUS_GOPAY_STEP_DEFINITIONS,
PLUS_GPC_STEP_DEFINITIONS,
SIGNUP_METHOD_EMAIL,
SIGNUP_METHOD_PHONE,
getAllSteps,
getLastStepId,
getPlusPaymentStepTitle,
getStepById,
getStepIds,
getSteps,
isPlusModeEnabled,
normalizePlusPaymentMethod,
normalizeSignupMethod,
};
});
+496
View File
@@ -0,0 +1,496 @@
# 本地自定义功能维护说明
> 目的:记录本仓库相对上游 `upstream/master` 需要长期保留的定制能力、关键代码位置和后续同步上游时的检查点。
>
> 最近一次整理基线:`upstream/master` Ultra2.0`87fc333`)已合入到本地同步分支 `sync/upstream-ultra20-20260428`,合并提交为 `413057b`
## 快速结论
后续同步上游时,需要优先保留以下本地功能:
1. Plus checkout 日本账单资料匹配与本地化地址填写。
2. 自定义邮箱池/账号池表格管理能力。
3. Hotmail / 2925 账号池搜索筛选。
4. Plus 免费试用资格提前判断,以及跳过账号仍标记“已用”。
5. SUB2API 多分组创建账号。
6. OAuth 登录使用全新标签页。
7. 接码平台多平台适配:HeroSMS + 5sim。
8. Plus 支付方式适配:PayPal + GoPay。
9. 本地导出配置 `/config.json` 忽略规则。
推荐每次同步后执行:
```bash
git diff --check
npm test
```
## 本地定制提交栈
在 Ultra2.0 合并后,本地相对 `upstream/master` 仍保留的定制提交主要是:
```text
3cf5f22 feat: upgrade custom email pool with manager UI and entry states
8d850be chore: 忽略本地导出配置
4d20160 fix(accounts): 统一流程完成后的账号标记
c52f505 fix(plus): 增强 checkout 免费试用校验
cc7671e feat(sub2api): 支持多分组创建账号
9f2c3eb feat(sidepanel): 添加账号池搜索筛选
b3790c1 fix(auth): OAuth 登录使用全新标签页
```
补充:接码平台多平台适配是当前分支新增的本地自定义功能;真实 5sim API Key 只允许通过侧边栏输入并保存到本地配置,不写入仓库、测试或文档。
排查本地定制范围可用:
```bash
git diff --stat upstream/master..HEAD
git diff --name-only upstream/master..HEAD
git log --oneline --reverse --no-merges upstream/master..HEAD
```
## 1. Plus checkout 日本资料匹配
### 目标
Plus checkout 填账单资料时,支持日本地区:
- 国家/地区选项可匹配 `JP``Japan``日本``日本国`
- 日本地址可走本地化接口路径 `/jp-address`
- 能识别日文表单标签,例如 `郵便番号``都道府県`
- 地区匹配支持 `東京都``Tokyo` 这类本地化/英文混合值。
### 关键文件
| 文件 | 作用 |
| --- | --- |
| `data/address-sources.js` | 国家别名和本地 fallback 地址种子,包含 `JP`。 |
| `background/steps/fill-plus-checkout.js` | Plus checkout 后台步骤,`COUNTRY_ENDPOINTS.JP` 指向 `/jp-address`。 |
| `content/plus-checkout.js` | checkout 页面内容脚本,负责国家、地区、地址字段定位和填写。 |
| `tests/address-sources.test.js` | 国家码与地址 seed 单测。 |
| `tests/plus-checkout-address-input.test.js` | 地址字段、国家、地区匹配单测。 |
| `tests/plus-checkout-billing-tab-resolution.test.js` | billing tab 和地址 seed 传递单测。 |
### 关键代码点
- `data/address-sources.js`
- `COUNTRY_ALIASES.JP`
- `LOCAL_ADDRESS_SEEDS.JP`
- `background/steps/fill-plus-checkout.js`
- `COUNTRY_ENDPOINTS.JP`
- `content/plus-checkout.js`
- `COUNTRY_OPTION_ALIASES.JP`
- `matchesCountryOption(...)`
- `matchesRegionOption(...)`
- 地址字段识别正则里对 `都道府県``郵便番号` 的支持。
### 维护注意
1. 上游如果重写 checkout 地址填写逻辑,不要只保留英文 `country/state/zip` 匹配,必须继续保留日文字段识别。
2. 上游如果调整地址接口配置,确认 `JP` 仍指向 `/jp-address` 或新的等效路径。
3. 合并后至少跑:
```bash
node --test \
tests/address-sources.test.js \
tests/plus-checkout-address-input.test.js \
tests/plus-checkout-billing-tab-resolution.test.js
```
## 2. 自定义邮箱池 / 账号池表格
### 目标
支持导入一批自定义注册邮箱,并在侧边栏以表格/列表方式管理:
- 导入邮箱。
- 搜索邮箱。
- 按状态筛选。
- 批量标记已用/未用。
- 批量启用/停用。
- 批量删除。
- 自动运行时按轮次取邮箱。
- 成功、跳过或完成后标记当前邮箱为“已用”。
### 关键文件
| 文件 | 作用 |
| --- | --- |
| `background.js` | 状态结构、邮箱池规范化、取当前轮邮箱、标记已用。 |
| `background/message-router.js` | 步骤完成/Plus 完成时调用标记已用逻辑。 |
| `sidepanel/sidepanel.html` | 自定义邮箱池 UI 区域和脚本加载。 |
| `sidepanel/sidepanel.js` | 侧边栏状态收集、DOM 绑定、manager 初始化。 |
| `sidepanel/custom-email-pool-manager.js` | 自定义邮箱池表格/列表管理器。 |
| `sidepanel/sidepanel.css` | 表格、搜索、筛选和批量操作样式。 |
| `tests/background-custom-email-pool.test.js` | 后台邮箱池逻辑测试。 |
| `tests/sidepanel-custom-email-pool.test.js` | 侧边栏脚本加载/基础 UI 测试。 |
### 关键代码点
后台:
- `background.js`
- `customEmailPool`
- `customEmailPoolEntries`
- `normalizeCustomEmailPoolEntryObjects(...)`
- `getCustomEmailPool(...)`
- `getCustomEmailPoolEntries(...)`
- `getCustomEmailPoolEmailForRun(...)`
- `markCurrentCustomEmailPoolEntryUsed(...)`
- `markCurrentRegistrationAccountUsed(...)`
- `background/message-router.js`
- step 完成和 Plus 完成路径里需要能调用 `markCurrentRegistrationAccountUsed(...)`
- fallback 兼容路径仍需保留 `markCurrentCustomEmailPoolEntryUsed(...)`
侧边栏:
- `sidepanel/sidepanel.html`
- `row-custom-email-pool`
- `input-custom-email-pool`
- `input-custom-email-pool-import`
- `input-custom-email-pool-search`
- `select-custom-email-pool-filter`
- `checkbox-custom-email-pool-select-all`
- `btn-custom-email-pool-bulk-used`
- `btn-custom-email-pool-bulk-unused`
- `btn-custom-email-pool-bulk-enable`
- `btn-custom-email-pool-bulk-disable`
- `btn-custom-email-pool-bulk-delete`
- `custom-email-pool-list`
- `sidepanel/custom-email-pool-manager.js`
- `createCustomEmailPoolManager(...)`
- 内部维护 `selectedEntryIds`、搜索词和筛选状态。
- `sidepanel/sidepanel.js`
- 读取并传入上述 DOM 节点。
- 初始化 `window.SidepanelCustomEmailPoolManager?.createCustomEmailPoolManager(...)`
### 同步上游时的高风险点
上游经常改侧边栏脚本加载顺序。合并时必须确认这些脚本都还在,并且在 `sidepanel.js` 之前加载:
```html
<script src="custom-email-pool-manager.js"></script>
<script src="ip-proxy-provider-711proxy.js"></script>
<script src="ip-proxy-panel.js"></script>
<script src="sidepanel.js"></script>
```
如果上游新增脚本,例如 IP 代理面板,不要用“二选一”的方式解决冲突;应该同时保留本地 `custom-email-pool-manager.js` 和上游新增脚本。
### 验证
```bash
node --test \
tests/background-custom-email-pool.test.js \
tests/sidepanel-custom-email-pool.test.js \
tests/background-registration-account-used.test.js
```
## 3. Hotmail / 2925 账号池搜索筛选
### 目标
账号池表格支持搜索和按状态筛选,便于维护大量账号。
### 关键文件
| 文件 | 作用 |
| --- | --- |
| `sidepanel/sidepanel.html` | Hotmail / 2925 搜索框和筛选下拉框。 |
| `sidepanel/sidepanel.js` | DOM 节点绑定并传入 manager。 |
| `sidepanel/hotmail-manager.js` | Hotmail 搜索、筛选、渲染。 |
| `sidepanel/mail-2925-manager.js` | 2925 搜索、筛选、渲染。 |
| `sidepanel/sidepanel.css` | 搜索框/筛选框样式。 |
### 关键 DOM
- Hotmail
- `input-hotmail-search`
- `select-hotmail-filter`
- 2925
- `input-mail2925-search`
- `select-mail2925-filter`
### 维护注意
上游如果重构账号池 UI,确认 manager 里这些事件仍存在:
- `dom.inputHotmailSearch?.addEventListener('input', ...)`
- `dom.selectHotmailFilter?.addEventListener('change', ...)`
- `dom.inputMail2925Search?.addEventListener('input', ...)`
- `dom.selectMail2925Filter?.addEventListener('change', ...)`
## 4. Plus 免费试用资格提前判断和账号标记
### 目标
Plus checkout 页面加载后尽早判断是否有免费试用资格:
- 如果“今日应付金额”不是 0,直接跳过支付填写/提交。
- 被跳过的账号也要标记为“已用”,避免下次重复拿到。
- 提交前再检查一次金额,防止页面中途变化。
### 关键文件
| 文件 | 作用 |
| --- | --- |
| `background/steps/fill-plus-checkout.js` | Plus 第 7 步提前检查金额并抛出非重试 sentinel。 |
| `content/plus-checkout.js` | checkout 页面读取 `checkoutAmountSummary`。 |
| `background.js` | `isPlusCheckoutNonFreeTrialError(...)` 和账号标记统一入口。 |
| `background/message-router.js` | Plus 完成或跳过路径统一标记账号已用。 |
| `tests/plus-checkout-billing-tab-resolution.test.js` | 非免费试用跳过逻辑测试。 |
| `tests/background-registration-account-used.test.js` | 账号标记使用 fresh state 的测试。 |
| `tests/background-luckmail.test.js` | LuckMail Plus 完成后标记测试。 |
### 关键代码点
- `background/steps/fill-plus-checkout.js`
- `inspectCheckoutAmountSummary(...)`
- `PLUS_CHECKOUT_NON_FREE_TRIAL::...`
- 调用 `markCurrentRegistrationAccountUsed(...)`
- `content/plus-checkout.js`
- `getCheckoutAmountSummary(...)`
- 返回 `checkoutAmountSummary`
- `background.js`
- `markCurrentRegistrationAccountUsed(...)`
- `isPlusCheckoutNonFreeTrialError(...)`
### 维护注意
1. 不要把免费资格判断放到账单地址或支付方式填写之后。
2. 不要把 `PLUS_CHECKOUT_NON_FREE_TRIAL::` 当作普通可重试错误。
3. 标记账号已用时要读取 fresh state,避免 step 局部传入的 state 过期。
## 5. Plus 支付方式适配:PayPal + GoPay
### 目标
Plus 模式新增 `plusPaymentMethod`
- `paypal`:保留现有 PayPal 账号池、登录与授权。
- `gopay`:创建 checkout 时使用印尼账单国家 `ID / IDR`,第 7 步选择 GoPay,第 8 步填写 GoPay 手机号;验证码优先使用侧边栏已填值,否则执行时弹出手动输入框;提交验证码后继续填写 GoPay PIN。
### 关键文件
| 文件 | 作用 |
| --- | --- |
| `sidepanel/sidepanel.html` | Plus 支付下拉、GoPay 手机号、GoPay 验证码、GoPay PIN 输入。 |
| `sidepanel/sidepanel.js` | 保存 `plusPaymentMethod / gopayCountryCode / gopayPhone / gopayOtp / gopayPin`,按支付方式切换配置行,第 8 步标题按 PayPal / GoPay 动态匹配,并提供 GoPay 验证码手动输入弹窗。 |
| `gopay-utils.js` | GoPay 支付方式、手机号、验证码、PIN 规范化工具。 |
| `content/plus-checkout.js` | 创建 GoPay checkout payload,识别/选择 GoPay 付款方式。 |
| `background/steps/fill-plus-checkout.js` | 第 7 步按支付方式选择 PayPal 或 GoPay,并为 GoPay 使用印尼地址。 |
| `background/steps/gopay-approve.js` | 第 8 步 GoPay 手机号、手动验证码、PIN 自动化骨架。 |
| `content/gopay-flow.js` | GoPay 页面手机号/验证码/PIN 输入脚本,包含国家区号切换、贴近人工点击的确认按钮事件序列,并在必要时交给后台 debugger 真实鼠标事件兜底。 |
| `content/whatsapp-flow.js` | WhatsApp Web 验证码读取脚本。 |
### 维护注意
1. GoPay 验证码和 PIN 只能通过侧边栏输入或运行时弹窗临时填写,不要写入代码、测试或文档。
2. PayPal 和 GoPay 共享 Plus 可见步骤,第 8 步仍使用 `paypal-approve` 这个 step key,但展示标题会按 `plusPaymentMethod` 变为 PayPal 或 GoPay 文案,后台也会按该字段分发到 PayPal 或 GoPay executor。
3. GoPay 页面和 WhatsApp Web 的真实 DOM 可能变化;后续联调时优先在每个页面抓 `GOPAY_GET_STATE / WHATSAPP_GET_STATE` 输出,再补精确选择器。
## 6. SUB2API 多分组创建账号
### 目标
支持一次配置多个 SUB2API 分组,完成 OAuth 后为多个分组创建账号。
### 关键文件
| 文件 | 作用 |
| --- | --- |
| `content/sub2api-panel.js` | 多分组解析、查询和创建账号。 |
| `sidepanel/sidepanel.html` | 分组输入框提示支持多个分组。 |
| `background.js` / `background/message-router.js` | payload 透传。 |
| `background/steps/platform-verify.js` | Plus platform verify 兼容。 |
| `tests/sub2api-panel-proxy.test.js` | SUB2API 代理与多分组测试。 |
### 关键代码点
- `content/sub2api-panel.js`
- `normalizeSub2ApiGroupNames(...)`
- `getGroupsByNames(...)`
- `sub2apiGroupIds`
- `sidepanel/sidepanel.html`
- `input-sub2api-group`
- placeholder`默认 codex;多个用逗号或换行分隔`
### 维护注意
上游如果只按单个 `sub2apiGroupId` 写回,需要保留本地 `sub2apiGroupIds` 数组逻辑。
## 7. OAuth 登录使用全新标签页
### 目标
OAuth 登录流程打开新标签页,避免复用旧页面导致状态污染。
### 关键文件
| 文件 | 作用 |
| --- | --- |
| `background/steps/oauth-login.js` | OAuth 登录步骤入口。 |
| `background/tab-runtime.js` | 新标签打开和导航等待能力。 |
### 维护注意
上游如果改 OAuth tab 打开方式,确认仍保留“全新标签页”语义,不要退回到复用已有 tab。
## 8. 接码平台多平台适配:HeroSMS + 5sim
### 目标
手机号验证 Step 9 支持按平台切换接码能力:
- 默认仍为 `HeroSMS`,老配置无需迁移。
- 新增 `5sim` 平台,下拉框在接码设置卡片中直接可见。
- 国家/地区列表、API Key、价格上限、余额、买号、查码、完成、取消、ban、复用均按当前 `phoneSmsProvider` 分发。当前侧边栏只开放三个常用地区:印度尼西亚、泰国、越南。
- HeroSMS 继续使用原 `heroSms*` 字段;5sim 使用独立 `fiveSim*` 字段,切换平台时不会互相覆盖草稿。
### 关键文件
| 文件 | 作用 |
| --- | --- |
| `phone-sms/providers/hero-sms.js` | HeroSMS 适配层,承接 HeroSMS 余额、价格、国家规范化等平台逻辑。 |
| `phone-sms/providers/five-sim.js` | 5sim 适配层,封装 profile、countries、prices、buy、check、finish、cancel、ban、reuse。 |
| `phone-sms/providers/registry.js` | 接码平台注册表,按 `phoneSmsProvider` 选择平台模块。 |
| `background.js` | 持久字段默认值、导入脚本、配置规范化、phone flow helper 注入。 |
| `background/phone-verification-flow.js` | Step 9 运行态按 activation.provider 分发后续查码/完成/取消/ban/reuse。 |
| `sidepanel/sidepanel.html` | 接码平台下拉、余额按钮、5sim operator 输入。 |
| `sidepanel/sidepanel.js` | 平台切换、地区列表联动、价格/余额查询、配置收集与恢复。 |
| `tests/five-sim-provider.test.js` | 5sim provider 单测。 |
| `tests/phone-verification-flow.test.js` | 5sim Step 9 buy/check/finish/reuse 分发测试。 |
| `tests/sidepanel-phone-verification-settings.test.js` | 接码平台下拉和侧边栏联动测试。 |
### 关键状态字段
- 通用:
- `phoneSmsProvider: "hero-sms" | "5sim"`
- HeroSMS
- `heroSmsApiKey`
- `heroSmsCountryId`
- `heroSmsCountryLabel`
- `heroSmsCountryFallback`
- `heroSmsMaxPrice`
- `heroSmsReuseEnabled`
- `heroSmsAcquirePriority`
- 5sim
- `fiveSimApiKey`
- `fiveSimCountryId`
- `fiveSimCountryLabel`
- `fiveSimCountryFallback`
- `fiveSimMaxPrice`
- `fiveSimOperator`
### 维护注意
1. 不要把真实 5sim key 写入代码、测试或文档;只允许用户在侧边栏输入。
2. 上游同步时如果改了 Step 9 手机号验证,必须保留 `currentPhoneActivation.provider` 驱动的后续分发。
3. 上游同步时如果改了 sidepanel 接码区域,必须保留 `select-phone-sms-provider`,且地区列表按平台加载:HeroSMS 数字国家 ID5sim 字符串 country slug;当前只展示并保存印度尼西亚、泰国、越南,避免旧配置中的英国/美国等地区回写。
4. 5sim 产品码当前固定为 `openai`operator 默认 `any`;价格上限为空时按价格目录最低可用价尝试。
### 验证
```bash
node --check phone-sms/providers/hero-sms.js
node --check phone-sms/providers/five-sim.js
node --check phone-sms/providers/registry.js
node --check background/phone-verification-flow.js
node --check sidepanel/sidepanel.js
node --test tests/five-sim-provider.test.js tests/phone-verification-flow.test.js tests/sidepanel-phone-verification-settings.test.js
```
## 9. 本地配置文件忽略
### 目标
避免本地导出的运行配置误提交。
### 关键文件
- `.gitignore`
- `/config.json`
### 维护注意
如果后续出现新的本地导出文件,先确认是否应该忽略,不要直接提交。
## 后续同步上游的建议流程
```bash
git status --short --branch
git fetch --prune upstream
git switch -c sync/upstream-YYYYMMDD
git merge --no-ff upstream/master
```
冲突处理优先级:
1. 同时保留上游新增能力和本地定制能力。
2. `background.js` / `background/message-router.js` 里不要丢失:
- `markCurrentRegistrationAccountUsed`
- `markCurrentCustomEmailPoolEntryUsed`
- 上游新增的 `refreshIpProxyPool` 等能力。
3. `sidepanel/sidepanel.html` 脚本冲突时不要二选一:
- 保留 `custom-email-pool-manager.js`
- 保留上游新增脚本。
4. `content/plus-checkout.js` 冲突时重点检查:
- 日本国家/地区匹配。
- checkout amount summary。
- 地址字段定位正则。
5. 合并后执行完整测试:
```bash
npm test
```
## 快速检查命令
检查日本地址能力:
```bash
rg -n "JP:|日本|jp-address|matchesCountryOption|matchesRegionOption|郵便番号|都道府県" \
data/address-sources.js \
background/steps/fill-plus-checkout.js \
content/plus-checkout.js \
tests
```
检查自定义邮箱池能力:
```bash
rg -n "customEmailPool|custom-email-pool|markCurrentCustomEmailPoolEntryUsed|createCustomEmailPoolManager" \
background.js \
background/message-router.js \
sidepanel \
tests
```
检查 Plus 免费资格提前跳过:
```bash
rg -n "PLUS_CHECKOUT_NON_FREE_TRIAL|checkoutAmountSummary|inspectCheckoutAmountSummary|markCurrentRegistrationAccountUsed" \
background \
content \
tests
```
检查 SUB2API 多分组:
```bash
rg -n "normalizeSub2ApiGroupNames|getGroupsByNames|sub2apiGroupIds|input-sub2api-group" \
content/sub2api-panel.js \
sidepanel/sidepanel.html \
tests
```
检查接码多平台适配:
```bash
rg -n "phoneSmsProvider|select-phone-sms-provider|PhoneSmsFiveSimProvider|PhoneSmsProviderRegistry|fiveSim" \
background.js \
background/phone-verification-flow.js \
phone-sms \
sidepanel \
tests
```
@@ -1,189 +0,0 @@
# 2026-04-16 Architecture Refactor Plan
## 目标
在不破坏现有 1~9 步自动化流程、消息流、状态机和测试保护面的前提下,逐步拆分当前超大的主文件,降低后续开发和排障成本。
## 当前事实
- 这是一个无构建步骤的 Manifest V3 扩展。
- `background.js` 是 classic service worker,当前依赖 `importScripts(...)`
- `sidepanel/sidepanel.html` 通过多个 `<script>` 直接加载脚本。
- 运行态核心链路依赖:
- `chrome.storage.session` 保存流程态
- `chrome.storage.local` 保存配置态
- `chrome.runtime.sendMessage` 贯穿 sidepanel / background / content scripts
- `chrome.tabs` / `chrome.webNavigation` / `chrome.debugger` 支撑步骤执行
- 测试不仅验证行为,还会直接从 `background.js` / `sidepanel.js` / `content/*.js` 按函数名提取源码执行。
## 已确认基线
- `bun test` 当前基线:`84 pass / 0 fail`
- 关键大文件规模:
- `background.js`: 7481 行
- `sidepanel/sidepanel.js`: 4531 行
- `content/signup-page.js`: 1969 行
## 当前结构压力点
### `background.js`
- 状态/配置、邮箱提供者、Tab 管理、消息路由、自动运行状态机、步骤 1~9 全部集中在单文件。
- 文件内同时存在:
- 纯函数
- 网络请求
- 存储读写
- 浏览器 API 调度
- 步骤业务逻辑
- 风险最高的区域不是“代码长”,而是“跨区域共享状态太多”。
### `sidepanel/sidepanel.js`
- 同时承担:
- DOM 查询
- 状态同步
- 配置读写
- 多个 provider 管理器
- 按钮事件绑定
- background 广播处理
- Hotmail / LuckMail / iCloud 三块都已经是天然的 feature slice,但仍堆在一个文件里。
## 重构硬约束
### 约束 1:先拆“强内聚功能块”,后拆“流程主干”
优先拆:
- Hotmail 账号池 UI
- LuckMail 管理 UI
- iCloud 别名管理 UI
- CPA / SUB2API 面板桥接层
暂缓拆:
- `executeStep1~9`
- `autoRunLoop`
- `handleMessage`
- `resolveVerificationStep`
原因:这些主干函数既是运行核心,也是当前测试抽取最密集的区域。
### 约束 2:第一阶段尽量不改变被测试函数的“定义位置”
由于现有测试会直接从源文件提取函数源码,第一阶段要避免大规模把被测函数从原文件移走。
### 约束 3:运行边界不能变
以下运行边界必须保持:
- `background.js` 继续是 service worker 入口
- `sidepanel/sidepanel.html` 继续是 sidepanel 入口
- content script 注入方式不变
- 所有 runtime message type 不变
- storage key 不变
## 目标结构
### 背景层目标
```txt
background.js
background/
state/
settings.js
session-state.js
runtime/
tabs.js
command-queue.js
logging.js
message-router.js
providers/
hotmail.js
luckmail.js
icloud.js
generated-email.js
steps/
step1.js
step2.js
step3.js
verification.js
step5.js
step6.js
step7.js
step8.js
step9.js
auto-run/
scheduler.js
loop.js
```
### Sidepanel 层目标
```txt
sidepanel/sidepanel.js
sidepanel/
hotmail-manager.js
luckmail-manager.js
icloud-manager.js
auto-run-ui.js
runtime-listeners.js
update-service.js
```
## 分阶段执行顺序
### Phase 1
先拆 `sidepanel` 的强内聚管理区块。
- 先拆 Hotmail manager
- 再拆 iCloud manager
- 再拆 LuckMail manager
原因:
- 不触碰主流程状态机
- 风险主要局限在 sidepanel UI
- 可以验证“多脚本 + 工厂上下文”的拆分模式
### Phase 2
`background.js` 中和主流程相对解耦的桥接层。
- CPA / SUB2API OAuth 桥接
- 邮箱 provider 的 API 访问层
- Cloudflare / Duck / iCloud 生成邮箱层
### Phase 3
`background.js` 中的运行时基础设施。
- tab registry
- command queue
- logging
- storage helpers
### Phase 4
最后拆步骤执行器与 auto-run 主循环。
- `executeStep1~9`
- `resolveVerificationStep`
- `autoRunLoop`
## 第一阶段落地策略
本轮先做:
1. 新增 `sidepanel/hotmail-manager.js`
2. 让 `sidepanel.js` 通过工厂方式接入 Hotmail manager
3. 不改变 runtime message type
4. 不改变 Hotmail 区块的 UI 行为
5. 跑现有测试确认基线仍然成立
## 验收标准
- `bun test` 继续全绿
- sidepanel Hotmail 区块行为无回归
- `sidepanel.js` 不再直接承载 Hotmail 账号池的大段渲染和事件逻辑
- 后续可以按同样模式继续拆 iCloud / LuckMail
@@ -1,271 +0,0 @@
# Hotmail OAuth Mail Pool Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Hotmail account pool with Microsoft OAuth authorization and Microsoft Graph mail polling, then wire it into the existing automation flow as a new mail provider.
**Architecture:** Keep the existing 1~9 step orchestrator in `background.js`, add a new `hotmail-api` provider path, and extend the side panel to manage Hotmail accounts. Preserve QQ, 163, and Inbucket behavior while introducing account allocation, token management, and Graph-based verification-code retrieval.
**Tech Stack:** Chrome Extension MV3, plain JavaScript, `chrome.identity.launchWebAuthFlow`, `fetch`, `chrome.storage.local`, `chrome.storage.session`
---
### Task 1: Extend State Model for Hotmail Accounts
**Files:**
- Modify: `background.js`
- Modify: `README.md`
- [ ] **Step 1: Write the failing test**
Document the expected account shape and provider behavior in code comments or development notes before implementation:
```js
// Expected local storage shape:
// hotmailAccounts: [{ id, email, password, clientId, accessToken, refreshToken, expiresAt, status, lastUsedAt, lastAuthAt, lastError }]
// mailProvider accepts 'hotmail-api'
```
- [ ] **Step 2: Run test to verify it fails**
Run a manual smoke check by loading the current extension and confirming there is no `hotmail-api` provider and no persisted Hotmail account state.
Expected: The provider does not exist yet and account state is absent.
- [ ] **Step 3: Write minimal implementation**
Add new persisted keys and runtime state helpers in `background.js`:
- `hotmailAccounts`
- `currentHotmailAccountId`
- helper functions to read, write, upsert, delete, and mark account status
- [ ] **Step 4: Run test to verify it passes**
Reload the extension and confirm Hotmail account state can be read and written through background message handlers.
- [ ] **Step 5: Commit**
```bash
git add background.js README.md
git commit -m "feat: add hotmail account state model"
```
### Task 2: Add Hotmail Account Pool UI
**Files:**
- Modify: `sidepanel/sidepanel.html`
- Modify: `sidepanel/sidepanel.css`
- Modify: `sidepanel/sidepanel.js`
- [ ] **Step 1: Write the failing test**
Describe the expected UI state:
- provider selector includes `hotmail-api`
- Hotmail account section renders a list
- user can add, authorize, test, and delete accounts
- [ ] **Step 2: Run test to verify it fails**
Reload the current extension.
Expected: No Hotmail provider option and no account section.
- [ ] **Step 3: Write minimal implementation**
Add:
- a new provider option
- a Hotmail accounts management section
- side panel event handlers for add, authorize, test, delete, and select current account status
- [ ] **Step 4: Run test to verify it passes**
Reload the extension and verify:
- the new provider is visible
- the section appears only when selected
- account rows render correctly from stored state
- [ ] **Step 5: Commit**
```bash
git add sidepanel/sidepanel.html sidepanel/sidepanel.css sidepanel/sidepanel.js
git commit -m "feat: add hotmail account pool panel"
```
### Task 3: Implement Microsoft OAuth Authorization
**Files:**
- Modify: `background.js`
- Modify: `manifest.json`
- [ ] **Step 1: Write the failing test**
Define the expected authorization flow in a focused helper-oriented checklist:
- PKCE code verifier/challenge can be created
- auth URL includes client ID, redirect URI, state, scope, and challenge
- callback code is parsed and validated
- token response updates the account record
- [ ] **Step 2: Run test to verify it fails**
Trigger the new `Authorize` action from the side panel.
Expected: The action is not implemented yet and fails.
- [ ] **Step 3: Write minimal implementation**
Add background handlers and helpers for:
- PKCE generation
- OAuth URL creation
- `chrome.identity.getRedirectURL()`
- `chrome.identity.launchWebAuthFlow`
- code exchange via Microsoft token endpoint
- account token persistence and error reporting
- [ ] **Step 4: Run test to verify it passes**
Manually authorize a Hotmail account and confirm:
- the login flow opens
- tokens are saved to the target account
- account status becomes `authorized`
- [ ] **Step 5: Commit**
```bash
git add background.js manifest.json
git commit -m "feat: add microsoft oauth authorization"
```
### Task 4: Implement Token Refresh and Graph Mail Polling
**Files:**
- Modify: `background.js`
- [ ] **Step 1: Write the failing test**
Define the expected helper behavior:
- expired access token refreshes with `refresh_token`
- Graph mail fetch returns recent inbox messages
- filtering returns the newest matching verification code after the requested timestamp
- [ ] **Step 2: Run test to verify it fails**
Use the side panel `Test Mail Access` action before implementing the Graph path.
Expected: The action fails because Graph mail polling does not exist yet.
- [ ] **Step 3: Write minimal implementation**
Add helpers in `background.js` for:
- token freshness check
- refresh-token grant request
- Graph inbox fetch
- mail filtering
- verification code extraction
- [ ] **Step 4: Run test to verify it passes**
Authorize a Hotmail account and verify the test action can fetch mailbox data and surface a success or meaningful “no matching mail” response.
- [ ] **Step 5: Commit**
```bash
git add background.js
git commit -m "feat: add graph mail polling"
```
### Task 5: Wire Hotmail Provider into Step 3, Step 4, Step 7, and Auto Run
**Files:**
- Modify: `background.js`
- Modify: `sidepanel/sidepanel.js`
- Modify: `README.md`
- [ ] **Step 1: Write the failing test**
Define the expected run behavior:
- Auto mode chooses a fresh authorized Hotmail account for each new run
- Step 3 uses the selected account email and password
- Step 4 and Step 7 read verification mail through Graph instead of mailbox tabs
- [ ] **Step 2: Run test to verify it fails**
Select `hotmail-api` and run a manual or auto flow.
Expected: The flow cannot yet allocate an account or fetch verification codes from Graph.
- [ ] **Step 3: Write minimal implementation**
Update:
- provider branching in `getMailConfig()` or a replacement provider resolver
- account allocation at fresh run start
- Step 3 account-backed credentials
- Step 4 and Step 7 Graph polling path
- auto-run preconditions for Hotmail accounts
- [ ] **Step 4: Run test to verify it passes**
Run:
1. manual Step 3 + Step 4 on a selected account
2. manual Step 6 + Step 7 on the same account
3. one full Auto run with `hotmail-api`
Expected: no mailbox tab is opened for Hotmail, and verification codes come from Graph.
- [ ] **Step 5: Commit**
```bash
git add background.js sidepanel/sidepanel.js README.md
git commit -m "feat: integrate hotmail api provider into automation flow"
```
### Task 6: Regression Verification and Cleanup
**Files:**
- Modify: `README.md`
- [ ] **Step 1: Write the failing test**
List the required regression checks:
- QQ provider still opens QQ mail tab
- 163 provider still opens 163 mail tab
- Inbucket provider still opens mailbox page
- Hotmail provider uses API path only
- [ ] **Step 2: Run test to verify it fails**
Manually inspect pre-change behavior expectations against the new code before cleanup.
Expected: Any missing provider branch or broken selector is identified.
- [ ] **Step 3: Write minimal implementation**
Clean up labels, update README usage instructions, and ensure all branches show accurate UI copy.
- [ ] **Step 4: Run test to verify it passes**
Reload the extension and perform:
- provider switch smoke test
- account add/delete smoke test
- one OAuth authorization smoke test
- one mail access smoke test
- [ ] **Step 5: Commit**
```bash
git add README.md
git commit -m "docs: document hotmail oauth mail provider"
```
@@ -1,197 +0,0 @@
# Hotmail OAuth + Graph Mail Design
## Goal
Replace the existing DuckDuckGo plus webmail polling flow with a Hotmail account pool that:
- authorizes each Hotmail account inside the extension via Microsoft OAuth
- stores per-account tokens locally
- selects a fresh account for each automated run
- fetches verification emails through Microsoft Graph instead of mailbox page DOM polling
The existing 1~9 step flow should remain intact wherever possible. The new work should be isolated to account selection, authorization, and email retrieval.
## Existing Constraints
- The project is a Manifest V3 Chrome extension with no build step.
- Runtime orchestration lives in `background.js`.
- The side panel is plain HTML/CSS/JS.
- Step 4 and Step 7 currently depend on provider-specific content scripts for QQ, 163, and Inbucket mailbox polling.
- Auto mode already supports retries, pauses, and restoring session state.
## Design Summary
The new Hotmail path introduces three focused subsystems:
1. `hotmail-account-pool`
Maintains a reusable list of Hotmail accounts and their OAuth credentials.
2. `microsoft-oauth`
Handles Microsoft authorization code flow with PKCE through `chrome.identity.launchWebAuthFlow`.
3. `hotmail-graph-mail`
Reads inbox messages from Microsoft Graph and extracts verification codes for Step 4 and Step 7.
The main flow continues to use the existing step state machine in `background.js`.
## Architecture
### 1. Account Pool
Persist a new `hotmailAccounts` array in `chrome.storage.local`.
Each account record stores:
- `id`
- `email`
- `password`
- `clientId`
- `accessToken`
- `refreshToken`
- `expiresAt`
- `status`
- `lastUsedAt`
- `lastAuthAt`
- `lastError`
The current run stores `currentHotmailAccountId` in `chrome.storage.session`.
When `mailProvider = hotmail-api`, Auto mode must allocate one account at the start of a fresh run, write its email into the existing `email` runtime field, and reuse the same account through Step 3, Step 4, Step 6, and Step 7.
### 2. OAuth Flow
Each account is authorized separately from the side panel.
Flow:
1. User clicks `Authorize` on an account row.
2. Background generates PKCE verifier/challenge and a random `state`.
3. Background launches Microsoft sign-in via `chrome.identity.launchWebAuthFlow`.
4. Microsoft redirects back to the extension redirect URL.
5. Background validates `state`, exchanges `code` for tokens, and updates the account record.
The extension requests delegated scopes only:
- `openid`
- `profile`
- `offline_access`
- `https://graph.microsoft.com/Mail.Read`
- `https://graph.microsoft.com/User.Read`
The design assumes one shared `clientId` across accounts is valid, while `refreshToken` remains per account.
### 3. Graph Mail Retrieval
Hotmail mail retrieval runs inside background logic and does not require a mail tab or content script.
The provider performs:
1. Resolve the current Hotmail account from session state.
2. Refresh the token if missing or near expiry.
3. Call Microsoft Graph to fetch recent inbox messages.
4. Filter by sender, subject, and time window.
5. Extract a 6-digit verification code from message metadata or preview/body.
6. Return the code to the existing Step 4 or Step 7 submission flow.
The first iteration should prefer stable fields such as:
- `from.emailAddress.address`
- `subject`
- `receivedDateTime`
- `bodyPreview`
Full HTML body parsing is explicitly deferred unless needed.
## Integration with Existing Steps
### Step 3
Step 3 keeps filling the OpenAI page in the same way, but when `mailProvider = hotmail-api`, the email comes from the selected account pool entry instead of the manual email box or Duck address.
### Step 4 and Step 7
The existing retry and resend behavior stays in place, but the provider path changes:
- old providers: `qq`, `163`, `inbucket`
- new provider: `hotmail-api`
The orchestration layer should branch before opening any mailbox tab. For `hotmail-api`, it calls a background helper instead of `sendToMailContentScriptResilient`.
### Auto Run
Auto run changes:
- remove the Duck auto-fetch dependency when `hotmail-api` is selected
- allocate a fresh Hotmail account at the start of a new run
- fail early if no authorized account is available
- preserve the existing retry and skip-failure semantics
## Side Panel Changes
The current single email entry remains for backward compatibility, but a new account-pool section is added when `hotmail-api` is selected.
New UI capabilities:
- list Hotmail accounts
- add an account
- delete an account
- authorize an account
- test mail access for an account
- show status and last error
The existing provider selector gains a `hotmail-api` option.
## Error Handling
The new path must surface actionable errors:
- no Hotmail account available
- missing `clientId`
- OAuth denied or cancelled
- token exchange failed
- refresh token invalid
- Graph mail read failed
- no matching verification mail found in the time window
Account-level failures should update the account record `status` and `lastError` without corrupting unrelated accounts.
## Security and Storage Tradeoffs
- Tokens and account passwords are stored in `chrome.storage.local` for operator convenience.
- This is acceptable for the current operator-managed extension model, but it increases the trust requirement of the local browser profile.
- No secret should be hard-coded in the repository.
## Testing Strategy
Because the project has no automated test harness today, implementation should carve out pure helper functions where possible and validate them with focused runtime checks.
The minimum verification surface:
- account selection logic
- PKCE helper generation
- OAuth callback parsing and state validation
- token refresh request/response handling
- Graph message filtering and code extraction
- Step 4 and Step 7 provider branch behavior
- Auto run allocation of a fresh account per run
## Deferred Work
The first version intentionally excludes:
- bulk import/export UX
- bulk authorize all accounts
- advanced HTML message parsing
- mailbox delete/move/archive behavior
- background local service or server proxy
- removal of existing QQ/163/Inbucket support
## Implementation Boundary
Modify only the minimum set of files needed to add the new provider while keeping current providers operational:
- `manifest.json`
- `background.js`
- `sidepanel/sidepanel.html`
- `sidepanel/sidepanel.css`
- `sidepanel/sidepanel.js`
If helper extraction becomes necessary, prefer adding small new files under `content/` or the repo root only if they clearly reduce complexity in `background.js`.
+70
View File
@@ -0,0 +1,70 @@
# 使用教程总索引
本目录用于维护 `Codex 注册扩展` 的分部分使用教程。
从现在开始,教程正文不再直接堆到这一个文件里,而是拆到 `分部分/` 目录下分别维护。
---
## 1. 当前目录说明
- `使用教程.md`
- 总索引、部分清单、维护规则
- `使用教程书写模板.md`
- 给 AI 的固定维护模板
- `分部分/*.md`
- 每个独立主题的正文文件
---
## 2. 当前已拆分部分
| 序号 | 部分标题 | 主要覆盖主题 | 文件 |
|---|---|---|---|
| 01 | 相关项目地址与部署说明 | `cpa``sub2api`、项目地址、部署前提、部署环境 | `docs/使用教程/分部分/01-相关项目地址与部署说明.md` |
| 02 | 更新扩展 | `git pull``GitHub Desktop`、手动覆盖更新、扩展重新加载 | `docs/使用教程/分部分/02-更新扩展.md` |
| 03 | Cloudflare Temp Email 使用说明 | `Cloudflare Temp Email``Admin Auth``Custom Auth`、随机子域、邮件接收 | `docs/使用教程/分部分/03-Cloudflare-Temp-Email-使用说明.md` |
| 04 | iCloud 隐私邮箱使用方法 | `iCloud+``隐藏邮件地址`、Apple ID、转发邮箱、刷新隐私邮箱 | `docs/使用教程/分部分/04-iCloud-隐私邮箱使用方法.md` |
| 05 | QQ 邮箱切换邮箱使用教程 | `QQ 邮箱`、英文邮箱、`Foxmail`、删除后重建 | `docs/使用教程/分部分/05-QQ-邮箱切换邮箱使用教程.md` |
| 06 | PayPal 注册与绑卡使用教程 | `PayPal`、注册、绑卡、钱包、身份认证、右上角通知 | `docs/使用教程/分部分/06-PayPal-注册与绑卡使用教程.md` |
| 07 | ChatGPT Plus 订阅说明(待人工审核) | `ChatGPT Plus`、订阅说明、支付流程说明、人工审核 | `docs/使用教程/分部分/07-0元试用-ChatGPT-Plus-教程.md` |
| 08 | Clash Verge 非港轮询配置 | `Clash Verge``非港轮询`、扩展脚本、规则模式、系统代理 | `docs/使用教程/分部分/08-Clash-Verge-非港轮询配置.md` |
---
## 3. 当前还未落成独立部分、但后续可能要新开的主题
下面这些主题在旧总述里已经提到过,但目前还没有单独的部分文件:
- `HeroSMS` 手机接码扩展使用教程
- 节点检测与纯净度检查网站使用教程
如果后续收到这些主题的新增内容,优先新开部分,而不是硬塞到现有 8 个文件里。
---
## 4. 维护规则
1. `使用教程.md` 只维护总览和部分清单
2. 具体正文只维护到 `分部分/*.md`
3. 更新已有主题时,直接改对应分部分文件
4. 新增主题且无法归到现有部分时,创建新的分部分文件
5. 新建部分后,必须同步更新本文件的部分清单
---
## 5. 不再继续放进教程正文的内容
旧版 `使用教程.md` 末尾的“破限提示词”不属于面向终端用户的使用教程正文。
后续如果还需要保留这类内部提示词,请单独放到“内部资料”目录中管理,不要继续混在公开使用教程里,也不要继续让前台教程 AI 把它当作用户教程的一部分。
---
## 6. 建议阅读顺序
如果是第一次使用,推荐按下面顺序阅读:
1. `01-相关项目地址与部署说明.md`
2. `02-更新扩展.md`
3. 根据你当前使用的邮箱方案,选择 `03``04``05`
4. 如果需要支付和订阅,再看 `06``07`
5. 如果需要代理配置,再看 `08`
@@ -0,0 +1,278 @@
# 使用教程书写与拆分模板
本文件用于约束 AI 后续维护 `docs/使用教程/` 目录时的判断方式、拆分规则和输出格式。
这套教程已经从“单一大文件”改成了“总索引 + 分部分正文文件”的结构。
后续不要再把所有内容直接堆回 `使用教程.md`
---
## 1. 目录结构
当前目录固定使用下面这套结构:
- `docs/使用教程/使用教程.md`
- 作用:总索引、部分清单、维护规则
- `docs/使用教程/使用教程书写模板.md`
- 作用:本模板,专门给 AI 使用
- `docs/使用教程/分部分/*.md`
- 作用:每一个独立教程部分的正文文件
维护原则:
1. `使用教程.md` 只放总览和部分列表
2. 具体教程正文只放在 `分部分/*.md`
3. 新增教程主题时,优先判断是否属于现有部分
4. 如果不属于现有部分,再新开一个部分文件
---
## 2. AI 维护流程
每次收到“更新教程”的任务时,必须先做下面 4 步:
1. 先读取 `docs/使用教程/使用教程.md`
2. 再读取 `docs/使用教程/分部分/` 下现有文件
3. 判断本次内容属于哪个已有部分
4. 如果没有匹配部分,再新建部分文件,并同步更新 `使用教程.md`
不要一上来直接写正文。
---
## 3. 先输出更新判断
AI 在真正改文档前,必须先给出一段“更新判断”。
固定格式如下:
```md
## 更新判断
- 操作类型:`更新现有部分` / `新建部分` / `只更新总索引`
- 目标部分标题:`...`
- 目标文件:`docs/使用教程/分部分/xx-xxx.md`
- 判断理由:`...`
- 是否需要同步更新使用教程.md`是` / `否`
```
要求:
- 如果能明确归到已有部分,就选 `更新现有部分`
- 如果现有部分都不合适,就选 `新建部分`
- 只有在修改部分列表、排序、说明文字时,才选 `只更新总索引`
---
## 4. 当前部分清单
AI 维护时默认按下面的范围归类:
| 序号 | 部分标题 | 主要覆盖主题 | 文件 |
|---|---|---|---|
| 01 | 相关项目地址与部署说明 | `cpa``sub2api`、项目地址、部署前提、部署环境 | `docs/使用教程/分部分/01-相关项目地址与部署说明.md` |
| 02 | 更新扩展 | `git pull``GitHub Desktop`、手动覆盖更新、扩展重新加载 | `docs/使用教程/分部分/02-更新扩展.md` |
| 03 | Cloudflare Temp Email 使用说明 | `Cloudflare Temp Email``Admin Auth``Custom Auth`、随机子域、邮件接收 | `docs/使用教程/分部分/03-Cloudflare-Temp-Email-使用说明.md` |
| 04 | iCloud 隐私邮箱使用方法 | `iCloud+``隐藏邮件地址`、Apple ID、转发邮箱、刷新隐私邮箱 | `docs/使用教程/分部分/04-iCloud-隐私邮箱使用方法.md` |
| 05 | QQ 邮箱切换邮箱使用教程 | `QQ 邮箱`、英文邮箱、`Foxmail`、删除后重建 | `docs/使用教程/分部分/05-QQ-邮箱切换邮箱使用教程.md` |
| 06 | PayPal 注册与绑卡使用教程 | `PayPal`、注册、绑卡、钱包、身份认证、右上角通知 | `docs/使用教程/分部分/06-PayPal-注册与绑卡使用教程.md` |
| 07 | ChatGPT Plus 订阅说明(待人工审核) | `ChatGPT Plus`、订阅说明、支付流程说明、人工审核 | `docs/使用教程/分部分/07-0元试用-ChatGPT-Plus-教程.md` |
| 08 | Clash Verge 非港轮询配置 | `Clash Verge``非港轮询`、扩展脚本、规则模式、系统代理 | `docs/使用教程/分部分/08-Clash-Verge-非港轮询配置.md` |
---
## 5. 什么时候要新建部分
满足任意一条,就应该新建部分,而不是硬塞到旧文件里:
1. 新内容的主题和现有 8 个部分都不匹配
2. 新内容会让已有文件明显跑题
3. 新内容已经形成独立功能块,后续大概率会继续单独维护
4. 新内容虽然在总述里提到过,但当前还没有专门的部分文件
典型需要新开的例子:
- `HeroSMS` 手机接码扩展使用教程
- 节点检测与纯净度检查网站使用教程
- 新的邮箱方案使用教程
- 新的代理配置方案
不应该新开的例子:
- 只是补充 `PayPal` 一个报错说明
- 只是更新 `Cloudflare Temp Email` 一个字段解释
- 只是修改 `Clash Verge` 脚本中的几行配置说明
---
## 6. 新建部分时的文件命名规则
如果需要新开部分,使用下面规则:
1. 放到 `docs/使用教程/分部分/`
2. 文件名格式:
```text
NN-部分标题.md
```
例如:
- `09-HeroSMS-手机接码扩展使用教程.md`
- `10-节点检测与纯净度检查网站使用教程.md`
规则要求:
- `NN` 使用两位数字
- 按当前排序往后递增
- 文件名直接使用中文标题,便于人工识别
- 新建后必须同步更新 `使用教程.md` 的部分清单
---
## 7. 每个部分文件的固定结构
每个分部分正文文件都按下面结构写:
```md
# 第X部分:部分标题
## 部分信息
- `section_slug`: `english-slug`
- `适用主题`: `主题 1``主题 2`
- `维护方式`: `直接更新本文件`
## 适用场景
- ...
## 准备内容
- ...
## 操作步骤
### 第一步:...
说明。
### 第二步:...
说明。
## 常见问题
### 问题 1
说明。
## 注意事项
- ...
```
要求:
- 继续使用标准 Markdown
- 不使用 HTML
- 命令、路径、按钮、文件名要用反引号
- 操作步骤按真实顺序来写
- 重要提示写在 `常见问题``注意事项`
---
## 8. 更新已有部分时的要求
如果本次属于已有部分:
1. 直接修改对应的分部分文件
2. 保持原有标题和主题边界
3. 如果只是补充小知识点,不要新开大段无关章节
4. 如果文件标题已经不够覆盖新内容,再考虑是否需要拆成新部分
更新已有部分时,优先做这几件事:
- 补充步骤
- 修正常见问题
- 更新注意事项
- 调整准备内容
---
## 9. 同步更新总索引时要改什么
以下情况必须同步修改 `docs/使用教程/使用教程.md`
- 新增了部分文件
- 删除了部分文件
- 调整了部分顺序
- 修改了部分标题
- 新增了“待补充主题”
以下情况通常不需要改总索引:
- 只是补充某个部分里的正文
- 只是修正文案
- 只是补充 FAQ
---
## 10. 输出格式要求
AI 最终输出必须分成两段:
### 第一段:更新判断
固定使用本模板第 3 节的格式。
### 第二段:文档结果
根据情况二选一:
1. 如果是更新已有部分
输出“修改后的完整分部分文件正文”
2. 如果是新建部分
输出:
- 新文件路径
- 新文件完整正文
- 需要补到 `使用教程.md` 清单中的新表格行
不要只输出零散段落。
要输出可直接覆盖文件的完整 Markdown。
---
## 11. 固定指令
把下面这段直接发给 AI
```md
请按 `docs/使用教程/使用教程书写模板.md` 执行。
你必须先读取:
1. `docs/使用教程/使用教程.md`
2. `docs/使用教程/分部分/` 下现有文件
然后先输出“更新判断”,判断本次内容属于:
- 更新现有部分
- 新建部分
- 只更新总索引
如果属于已有部分,就直接输出该部分文件的完整新版本。
如果不属于已有部分,就新开一个部分文件,并同时给出需要补到 `使用教程.md` 的部分清单项。
要求:
1. 输出必须是 Markdown
2. 不要使用 HTML
3. 不要把所有内容重新堆回 `使用教程.md`
4. 每个教程主题优先归到已有分部分文件
5. 只有在现有部分都不合适时,才允许新开部分
6. 文件名、路径、接口、按钮名称要用反引号包裹
7. 面向第一次使用的人来写
8. 步骤必须按真实顺序写
```
@@ -0,0 +1,62 @@
# 第一部分:相关项目地址与部署说明
## 部分信息
- `section_slug`: `project-and-deployment`
- `适用主题`: `cpa``sub2api`、项目地址、部署前提、部署环境
- `维护方式`: `直接更新本文件`
## 适用场景
- 需要拉取并部署 `cpa` 项目
- 需要拉取并部署 `sub2api` 项目
- 需要先确认项目地址和部署前提
## 准备内容
- 可以访问 `GitHub`
- 一个可用的本地目录或服务器目录
- 基础的 `git` 使用能力,或者可以手动下载压缩包
- 如果要部署 `cpa`,部署环境必须可以访问 `OpenAI`
## 操作步骤
### 第一步:确认项目地址
- `cpa` 项目地址:`https://github.com/router-for-me/CLIProxyAPI`
- `sub2api` 项目地址:`https://github.com/Wei-Shaw/sub2api`
先确认你本次要部署的是哪个项目,再决定后续拉取和配置范围。
### 第二步:拉取项目到本地
可以用 `git clone` 拉取,也可以直接下载压缩包后解压。
如果你准备后续持续更新,建议优先使用 `git clone`
### 第三步:确认部署方式
本教程只负责把项目地址和部署前提说明清楚,不在这里展开完整部署命令。
项目拉取到本地后,可以再让 AI 结合当前环境继续完成部署。
### 第四步:确认 `cpa` 的环境要求
如果你部署的是 `cpa`,部署所在环境必须可以访问 `OpenAI`
如果环境本身访问不到 `OpenAI`,后续认证流程可能会异常。
## 常见问题
### 为什么部署前要先分清 `cpa``sub2api`
因为两者仓库不同、运行方式不同、后续配置项也不同。
如果一开始就搞混,后面让 AI 继续部署时很容易走错方向。
### 为什么认证看起来成功了,但没有生成认证文件?
常见原因是 `cpa` 部署环境无法访问 `OpenAI`
这种情况下,前面看起来像是执行完成了,但关键结果并没有真正落盘。
## 注意事项
- 先确认本次目标项目,再继续部署
- 需要长期维护时,尽量用 `git` 拉取,而不是每次手动覆盖
- 部署 `cpa` 前,先确认环境确实能访问 `OpenAI`
@@ -0,0 +1,77 @@
# 第二部分:更新扩展
## 部分信息
- `section_slug`: `update-extension`
- `适用主题`: `git pull``GitHub Desktop`、手动下载覆盖、扩展重新加载
- `维护方式`: `直接更新本文件`
## 适用场景
- 已经安装过扩展,想更新到最新版本
- 想知道哪种更新方式更省事
- 更新完成后不知道浏览器里还要不要再操作一次
## 准备内容
- 当前扩展的本地文件夹
- 可以打开浏览器的 `扩展程序管理` 页面
- 至少具备下面 3 种更新方式中的一种:
- `GitHub Desktop`
- `git`
- 手动下载最新压缩包
## 操作步骤
### 第一步:选择更新方式
推荐优先级如下:
1. `git pull`
2. `GitHub Desktop`
3. 手动下载覆盖
### 第二步:使用 `GitHub Desktop` 更新
如果你已经把仓库交给 `GitHub Desktop` 管理,后续只需要在软件里拉取最新提交即可。
这种方式适合不想直接敲命令的人。
### 第三步:使用 `git pull` 更新
这是最方便、最推荐的方式。
1. 先安装 `git`
2. 打开终端
3. 执行 `cd 扩展文件夹路径`
4. 再执行 `git pull`
### 第四步:手动下载覆盖更新
如果你不用 `git`,也可以直接下载最新版本到本地,然后覆盖现有扩展文件夹。
这种方式适合临时更新,但不适合频繁维护。
### 第五步:重新加载扩展
不论你用了哪种更新方式,更新完成后都必须:
1. 打开浏览器的 `扩展程序管理`
2. 找到当前扩展
3. 手动点击一次 `重新加载`
## 常见问题
### 为什么我已经更新文件了,但浏览器里还是旧版本?
因为浏览器不会自动重新读取本地扩展目录。
如果你没有点击 `重新加载`,浏览器可能仍在继续使用旧版本。
### 哪种方式最推荐?
推荐使用 `git pull`
它最省事,也最适合长期更新。
## 注意事项
- 更新完成后一定要手动 `重新加载` 扩展
- 频繁维护时,尽量不要只靠手动下载覆盖
- 如果本地改动较多,执行 `git pull` 前先确认是否需要备份本地修改
@@ -0,0 +1,95 @@
# 第三部分:Cloudflare Temp Email 使用说明
## 部分信息
- `section_slug`: `cloudflare-temp-email`
- `适用主题`: `Cloudflare Temp Email``Admin Auth``Custom Auth`、随机子域、邮件接收
- `维护方式`: `直接更新本文件`
## 适用场景
- 需要把 `Cloudflare Temp Email` 用作 `邮箱生成`
- 需要把 `Cloudflare Temp Email` 用作 `邮箱服务`
- 需要同时配置 `Temp API`、认证信息、域名和收件邮箱
## 准备内容
- 一个可用的 `Cloudflare Temp Email` 后端地址
- 如果要使用随机子域,对应域名解析已经提前配置好
- 后端的 `admin auth`
- 如果站点额外设置了访问密码,对应的访问认证信息
- 一个真正用于接收转发邮件的收件邮箱
## 操作步骤
### 第一步:先确认当前用途
`Cloudflare Temp Email` 可以同时承担两类角色:
- `邮箱生成`
- `邮箱服务`
如果两边都选择了它,就需要把两套配置都填完整。
### 第二步:填写 `Temp API`
在插件中先填写 `Temp API`,例如:
- `https://your-worker-domain`
不论你把它用于 `邮箱生成` 还是 `邮箱服务`,这一项都必须先配好。
### 第三步:按需填写 `Admin Auth`
如果你把 `邮箱生成` 选择成 `Cloudflare Temp Email`,就需要填写 `Admin Auth`
它对应后端配置里的 `admin auth`
### 第四步:按需填写 `Custom Auth`
`Custom Auth` 只有在站点额外开启访问密码时才需要填写。
如果没有这层额外访问密码,留空即可。
它不会替代 `Admin Auth`
### 第五步:配置 `Temp 域名`
这里填写允许创建邮箱的基础域名。
即使你开启了 `随机子域`,这里仍然填写基础域名,而不是随机出来的子域名。
### 第六步:按需开启 `随机子域`
只有在 `邮箱生成 = Cloudflare Temp Email` 时,这一项才会生效。
启用前需要先确认:
- 后端已经配置 `RANDOM_SUBDOMAIN_DOMAINS`
- Cloudflare DNS 已经设置 `MX *`
### 第七步:作为 `邮箱服务` 时填写 `邮件接收`
如果 `邮箱服务` 也选了 `Cloudflare Temp Email`,还需要填写真正的收件邮箱。
后续转发邮件会送到这里。
### 第八步:查看后端搭建参考
如果你还没有部署后端,可以参考:
- `https://linux.do/t/topic/316819`
## 常见问题
### 为什么我明明配了 `Temp API`,还是不能生成邮箱?
先确认:
- `邮箱生成` 是否真的选了 `Cloudflare Temp Email`
- `Admin Auth` 是否正确
- `Temp 域名` 是否正确
### 为什么随机子域没有生效?
通常是因为后端没有配置 `RANDOM_SUBDOMAIN_DOMAINS`,或者 Cloudflare DNS 没有完成 `MX *` 设置。
## 注意事项
- 如果同时把它用作 `邮箱生成``邮箱服务`,要把两边相关字段都检查一遍
- `Custom Auth` 只有额外访问密码场景才需要填写
- 开启随机子域前,先确认后端和 DNS 已经准备好
@@ -0,0 +1,112 @@
# 第四部分:iCloud 隐私邮箱使用方法
## 部分信息
- `section_slug`: `icloud-private-email`
- `适用主题`: `iCloud+``隐藏邮件地址`、Apple ID、转发邮箱、插件刷新
- `维护方式`: `直接更新本文件`
## 适用场景
- 需要把 `iCloud+``隐藏邮件地址` 用作隐私邮箱
- 需要先在 Apple 设备上开通 `iCloud 邮件`
- 需要在插件中读取已经创建好的隐私邮箱
## 准备内容
- 一个已开通 `iCloud+` 的 Apple ID
- 一台可登录该 Apple ID 的 `iPhone``iPad``Mac`
- 一个真正用于接收转发邮件的邮箱
- 插件中可正常使用的邮箱服务配置
## 操作步骤
### 第一步:准备 Apple ID
如果条件允许,建议使用非日常主力 Apple ID。
这样即使后续触发风控,也不会影响你平时常用的账号。
### 第二步:在 `iPhone``iPad` 上启用 `iCloud 邮件`
1. 打开 `设置`
2. 点击顶部你的姓名
3. 进入 `iCloud`
4. 找到 `邮件`
5. 打开右侧开关
如果是首次开通,按提示创建一个 `@icloud.com` 主邮箱地址。
### 第三步:在 `Mac` 上确认 `iCloud 邮件` 已开启
1. 打开苹果菜单
2. 进入 `系统设置`
3. 点击你的姓名
4. 进入 `iCloud`
5. 确认 `iCloud 邮件` 已启用
### 第四步:进入 `隐藏邮件地址`
`iCloud` 页面往下找到 `iCloud+`,然后进入 `隐藏邮件地址`
### 第五步:先手动创建一批隐私邮箱
建议先手动创建一批,例如 `20` 个左右。
这样插件后续读取时更稳定,也更容易判断整个流程有没有打通。
同时确认 `转发至` 已经设置为你真正接收邮件的邮箱。
### 第六步:配置插件中的邮箱服务
在插件里把 `邮箱服务` 设置为你真正接收转发邮件的邮箱,并确认它能正常收信。
### 第七步:配置插件中的邮箱生成
1. 将 `邮件生成` 选择为 `iCloud 隐私邮箱`
2. 先在网页中登录你的 `iCloud`
3. 回到插件的隐私邮箱配置区域
4. 点击刷新
5. 等待插件读取你已经创建好的地址
### 第八步:选择邮箱使用方式
建议优先使用 `复用未使用的`
如果你希望释放数量,可以勾选 `使用后删除`
如果不删除,这些地址可以保留为长期邮箱。
### 第九步:控制创建频率
建议每天最多新增 `3` 个左右。
测试中一天创建过多容易触发 `iCloud` 风控。
### 第十步:查看官方说明
- `Create a primary email address for iCloud Mail`
- `Create and edit Hide My Email addresses on iCloud.com`
## 常见问题
### 插件刷新后没有看到任何邮箱怎么办?
先确认:
- 你已经在网页里登录了 `iCloud`
- 你已经手动创建过隐私邮箱
- `转发至` 已经设置好
确认后,再回插件里刷新一次。
### 要不要勾选 `使用后删除`
如果你想及时释放可用数量,可以勾选。
如果你想把地址当长期邮箱保留,可以不勾选。
### 为什么不建议大量新建?
因为短时间创建过多容易触发 `iCloud` 风控,后面会影响正常使用。
## 注意事项
- 优先使用非日常主力 Apple ID
- 建议先手动创建一批地址,再让插件读取
- 一天不要大量新建,建议控制在 `3` 个左右
- 如果长期不删除,数量可能会较快达到上限
@@ -0,0 +1,58 @@
# 第五部分:QQ 邮箱切换邮箱使用教程
## 部分信息
- `section_slug`: `qq-mail-switch`
- `适用主题`: `QQ 邮箱`、英文邮箱、`Foxmail`、删除后重建
- `维护方式`: `直接更新本文件`
## 适用场景
- 当前 `QQ 邮箱` 地址需要切换
- 想继续复用同一个 `QQ 邮箱` 主账号
- 需要创建和删除英文邮箱、`Foxmail` 邮箱
## 准备内容
- 一个可以正常登录的 `QQ 邮箱`
- 可以访问 `账号与安全` 页面
## 操作步骤
### 第一步:登录 `QQ 邮箱`
先登录你当前正在使用的 `QQ 邮箱`
### 第二步:进入 `账号与安全`
打开:
- `https://wx.mail.qq.com/account/index?sid=zdd4Voy7S04uZjBnAKhFZQAA#/`
### 第三步:进入 `账号管理`
`账号与安全` 页面中找到 `账号管理` 入口。
### 第四步:创建英文邮箱和 `Foxmail` 邮箱
`账号管理` 中:
1. 创建一个英文邮箱地址
2. 再创建一个 `Foxmail` 邮箱地址
### 第五步:使用后删除并重新创建
这两个地址使用完后,可以直接删除。
删除后重新创建新的英文邮箱和 `Foxmail` 邮箱,就可以继续使用。
## 常见问题
### 这些地址删除后还能继续重复创建吗?
可以。
按原流程删除后,再创建新的英文邮箱和 `Foxmail` 邮箱即可继续使用。
## 注意事项
- 使用完成后尽量及时删除,避免混淆当前正在使用的地址
- 建议每次切换后都确认插件里使用的是最新邮箱地址
@@ -0,0 +1,114 @@
# 第六部分:PayPal 注册与绑卡使用教程
## 部分信息
- `section_slug`: `paypal-registration-and-card-binding`
- `适用主题`: `PayPal`、个人账户注册、绑卡、钱包、身份认证、通知处理
- `维护方式`: `直接更新本文件`
## 适用场景
- 需要注册 `PayPal` 个人账户
- 需要给 `PayPal` 绑卡
- 绑卡后需要处理通知或身份认证
## 准备内容
- 一个可正常接收短信的手机号
- 一张可在线支付的借记卡或信用卡
- 能正常访问 `PayPal`
## 操作步骤
### 第一步:打开注册页面
打开:
- `https://www.paypal.com/signin`
然后点击 `注册`
### 第二步:选择账户类型
选择 `个人账户`
### 第三步:选择国家或地区
如果你按中国大陆居民流程注册,直接在 `国家/地区` 中选择 `中国`
### 第四步:输入手机号并继续
按页面提示输入手机号。
如果出现短信验证,就先完成验证。
### 第五步:填写登录信息和个人信息
按页面提示填写:
- 邮箱
- 密码
- 姓名
- 出生日期
- 地址
- 联系方式
中国大陆居民注册时,姓名建议按身份证上的中文姓名填写,不要使用拼音。
### 第六步:完成邮箱确认
注册成功后,按页面或邮件提示确认邮箱地址。
如果页面继续要求验证手机号,也一并完成。
### 第七步:进入 `钱包` 或主页开始绑卡
注册完成后,进入主页或 `钱包` 页面,找到 `关联卡或银行账户`
### 第八步:选择关联借记卡或信用卡
点击 `关联借记卡或信用卡`,进入绑卡流程。
### 第九步:填写银行卡信息
按页面提示填写:
- 卡号
- 有效期
- 安全代码
- 账单地址
如果卡背面分成两段数字,安全代码通常取三位数那一段。
### 第十步:完成绑卡并检查结果
点击 `关联卡`
有时页面会提示报错,但卡实际上已经绑定成功,所以还要再回 `钱包` 页面确认一次。
### 第十一步:查看右上角通知并完成认证
如果页面右上角通知标红,或者提示账户需要认证,请按提示补交资料。
常见情况是上传身份证件。
## 常见问题
### 绑卡时报错怎么办?
先去 `钱包` 页面再确认一次。
有时页面虽然提示失败,但实际已经绑定成功。
如果还没成功,再检查:
- 账单地址是否正确
- 卡片是否支持在线支付
- 银行是否拦截了临时授权验证
### 右上角通知标红怎么办?
先点开通知,看具体要求。
如果要求 `Confirm your Identity` 或类似身份认证,就按提示补交资料。
## 注意事项
- 中国大陆居民注册时,姓名建议使用身份证上的中文姓名
- 新绑卡时可能会触发最高 `1 USD` 或等值货币的临时授权验证
- 更稳妥的做法是使用单独管理、余额较低的借记卡
- 绑卡后记得同时检查 `钱包` 页面和右上角通知
@@ -0,0 +1,58 @@
# 第七部分:ChatGPT Plus 订阅说明(待人工审核)
## 部分信息
- `section_slug`: `chatgpt-plus-subscription-review-needed`
- `适用主题`: `ChatGPT Plus`、订阅说明、支付流程说明、人工审核
- `维护方式`: `暂不交给前台教程 AI 自动维护`
## 适用场景
- 需要整理 `ChatGPT Plus` 相关订阅说明
- 需要区分“公开用户教程”和“内部测试脚本或特殊链路”
## 当前状态说明
旧版总教程里,这一部分包含了脚本、支付参数、地区参数和非常规 checkout 链路。
这类内容不适合继续作为普通用户教程正文自动扩写,也不适合继续直接喂给前台教程 AI。
## 后续处理建议
### 第一条:不要继续把这部分当作普通用户教程维护
后续如果只是要维护公开教程,请不要在这里继续追加脚本、参数构造或非常规支付链路。
### 第二条:如确需保留,转入内部资料目录
如果团队内部仍然需要保留原始测试材料,请单独移动到内部资料目录中管理,并加上人工审核说明。
### 第三条:如要面向普通用户写订阅说明,只能基于官方公开入口
后续如果要补一版“普通用户可读的 Plus 订阅说明”,建议只基于:
- 官方公开入口
- 官方帮助中心
- 常规支付与账户说明
不要继续沿用旧版中的非常规流程描述。
## 常见问题
### 为什么这一部分没有继续按旧内容拆成可直接发布的教程?
因为旧内容已经超出了普通用户教程的范围,不适合继续作为公开教程自动维护。
### 如果以后确实要补这部分,应该怎么做?
先人工确认范围,再决定:
- 是写“公开用户教程”
- 还是写“内部资料”
这两类内容不能继续混写在同一个分部分文件里。
## 注意事项
- 本文件当前是“待人工审核”的占位说明
- 不要把脚本、非常规支付链路或特殊参数继续并入公开教程
- 如果未来重写本部分,请优先采用官方公开资料
@@ -0,0 +1,165 @@
# 第八部分:Clash Verge 非港轮询配置
## 部分信息
- `section_slug`: `clash-verge-non-hk-rotation`
- `适用主题`: `Clash Verge``非港轮询`、扩展脚本、规则模式、系统代理
- `维护方式`: `直接更新本文件`
## 适用场景
- 需要在 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev) 中启用 `🔁 非港轮询`
- 需要给现有订阅注入负载均衡分组
- 需要确保 `规则模式``系统代理` 生效
## 准备内容
- 已安装 [Clash Verge](https://github.com/clash-verge-rev/clash-verge-rev)
- 已导入可用订阅
- 可以打开 `订阅``代理` 页面
## 操作步骤
### 第一步:进入 `订阅` 并打开全局扩展脚本
1. 打开 `Clash Verge`
2. 进入左侧 `订阅``Profiles`
3. 找到并打开 `全局扩展脚本`
### 第二步:替换脚本内容
先清空旧内容,再粘贴下面脚本,然后保存。
```javascript
function uniqPrepend(arr, items) {
if (!Array.isArray(arr)) arr = [];
for (var i = items.length - 1; i >= 0; i--) {
var item = items[i];
var exists = false;
for (var j = 0; j < arr.length; j++) {
if (arr[j] === item) {
exists = true;
break;
}
}
if (!exists) arr.unshift(item);
}
return arr;
}
function upsertGroup(groups, group) {
for (var i = 0; i < groups.length; i++) {
if (groups[i] && groups[i].name === group.name) {
groups[i] = group;
return groups;
}
}
groups.unshift(group);
return groups;
}
function main(config, profileName) {
if (!config) return config;
if (!Array.isArray(config["proxy-groups"])) {
config["proxy-groups"] = [];
}
var groups = config["proxy-groups"];
var LB_NAME = "🔁 非港轮询";
var excludeRegex =
"(?i)(" +
"香港|hong[ -]?kong|\\bhk\\b|\\bhkg\\b|🇭🇰" +
"|剩余流量|套餐到期|下次重置剩余|重置剩余|到期时间|流量重置" +
"|traffic|expire|expiration|subscription|subscribe|reset|plan" +
")";
groups = upsertGroup(groups, {
name: LB_NAME,
type: "load-balance",
strategy: "round-robin",
"include-all-proxies": true,
"exclude-filter": excludeRegex,
url: "https://www.gstatic.com/generate_204",
interval: 300,
lazy: true,
"expected-status": 204
});
var injected = false;
var entryNameRegex = /节点选择|代理|Proxy|PROXY|默认|GLOBAL|全局|选择/i;
for (var i = 0; i < groups.length; i++) {
var g = groups[i];
if (!g || g.type !== "select") continue;
if (entryNameRegex.test(g.name || "")) {
if (!Array.isArray(g.proxies)) g.proxies = [];
g.proxies = uniqPrepend(g.proxies, [LB_NAME]);
injected = true;
}
}
if (!injected) {
for (var k = 0; k < groups.length; k++) {
var g2 = groups[k];
if (g2 && g2.type === "select") {
if (!Array.isArray(g2.proxies)) g2.proxies = [];
g2.proxies = uniqPrepend(g2.proxies, [LB_NAME]);
break;
}
}
}
config["proxy-groups"] = groups;
return config;
}
```
### 第三步:保存脚本
使用右上角保存按钮,或者按 `Ctrl+S` 保存。
### 第四步:切换到 `代理`
回到左侧 `代理``Proxies`)页面,也就是首页。
### 第五步:选择 `🔁 非港轮询`
在顶部常见的分组里,例如:
- `节点选择`
- `Proxy`
- `当前节点`
找到对应下拉框,然后选择 `🔁 非港轮询`
### 第六步:确认运行模式
继续确认以下两项:
1. `代理模式` 已设置为 `规则模式``Rule`
2. `系统代理``System Proxy`)已经开启
## 常见问题
### 为什么没有看到 `🔁 非港轮询`
先确认:
- 脚本已经完整粘贴并保存
- 当前订阅本身可用
- 当前模式是 `规则模式`
- `系统代理` 已开启
### 粘贴脚本后报格式错误怎么办?
先确认是否把旧内容完全清空,再重新完整粘贴。
如果仍然报错,可以让 AI 只帮你修复脚本格式,不要同时改动脚本逻辑。
## 注意事项
- 粘贴新脚本前,先把旧脚本清空
- 必须完整保存后再去 `代理` 页面检查
- 如果更新了脚本逻辑,优先检查 `🔁 非港轮询` 是否仍被正确注入到选择分组中
-312
View File
@@ -1,312 +0,0 @@
# 使用教程书写模板
本文件用于约束 AI 生成“账号贡献站 / Codex 注册扩展”使用教程时的 Markdown 格式。
使用方式:
- 先把这份文件发给 AI
- 再告诉 AI 本次要写哪一部分教程
- 让 AI 严格按本文格式输出
- 输出结果直接复制到管理端“使用教程”编辑区
---
## 1. 教程用途
使用教程用于说明:
- 页面怎么用
- 上传文件怎么准备
- 上传步骤是什么
- 常见错误怎么理解
- 注意事项有哪些
教程不是公告,所以要:
- 结构完整
- 步骤清楚
- 能让第一次接触的人看懂
---
## 2. 教程总体要求
AI 生成教程时必须满足:
1. 使用标准 Markdown
2. 不使用 HTML
3. 章节分明
4. 按用户实际操作顺序来写
5. 重点步骤必须编号
6. 文件名、路径、接口、按钮名要用反引号包裹
7. 语言要清楚,不要写废话
---
## 3. 标准结构
教程建议按这个结构输出:
```md
# 教程标题
一句话说明这份教程是教什么的。
## 适用场景
- 场景 1
- 场景 2
## 准备内容
- 需要准备的内容 1
- 需要准备的内容 2
## 操作步骤
### 第一步:...
说明。
### 第二步:...
说明。
### 第三步:...
说明。
## 常见问题
### 问题 1
说明。
### 问题 2
说明。
## 注意事项
- 注意项 1
- 注意项 2
```
---
## 4. 标题规范
标题必须直接说清主题。
推荐示例:
- `# 账号贡献页使用教程`
- `# 如何准备可上传的 JSON 文件`
- `# 账号贡献上传操作说明`
不推荐:
- `# 使用方法`
- `# 教你怎么弄`
- `# 说明一下`
---
## 5. 正文写法规范
### 5.1 先讲适用场景
教程开头要先告诉用户:
- 这份教程适合谁
- 能解决什么问题
示例:
```md
本教程适用于首次使用账号贡献页的用户,帮助你完成文件准备、上传和结果查看。
```
### 5.2 步骤必须按顺序写
不要跳步,不要先写报错再写操作。
推荐:
```md
## 操作步骤
### 第一步:准备 JSON 文件
...
### 第二步:打开贡献页面
...
### 第三步:填写昵称并上传
...
```
### 5.3 命令、按钮、文件名要加反引号
例如:
- `验证并上传`
- `codex-*.json`
- `sub2api-account-*.json`
- `POST /api/upload`
---
## 6. 常见问题写法
常见问题建议用二级或三级标题展开:
```md
## 常见问题
### 为什么按钮没有出现?
如果后台没有开启教程显示,前台不会显示“使用教程”按钮。
### 为什么文件上传失败?
请先检查文件命名、文件大小和内容格式是否符合要求。
```
---
## 7. 禁止项
AI 生成教程时禁止:
- 使用 HTML
- 写内部开发实现细节
- 写成公告语气
- 写成 PR 说明
- 大段堆文字不分章节
- 用“应该”“可能”“大概”这种模糊表达描述核心步骤
不要写:
- “这里我做了一个功能”
- “你应该差不多知道怎么操作”
- “这个问题暂时先这样”
---
## 8. 推荐语气
推荐风格:
- 清楚
- 分步骤
- 面向第一次使用的人
- 说明明确,不省略关键动作
推荐表达:
- “先准备”
- “然后打开”
- “接着点击”
- “如果出现以下情况”
- “请确认”
---
## 9. AI 生成教程时的固定指令
把下面这段直接发给 AI
```md
请帮我写一份“账号贡献站 / Codex 注册扩展”的使用教程,严格遵守以下要求:
1. 输出必须是 Markdown
2. 不要使用 HTML
3. 面向第一次使用的人来写
4. 结构必须清楚,章节明确
5. 操作步骤必须按顺序写
6. 文件名、接口、按钮名称、路径要用反引号包裹
7. 不要写内部开发过程
8. 重要部分要短句、短段落,不要写成大段废话
请按以下结构输出:
# 标题
一句话说明
## 适用场景
- ...
## 准备内容
- ...
## 操作步骤
### 第一步:...
...
### 第二步:...
...
## 常见问题
### ...
...
## 注意事项
- ...
```
---
## 10. 可直接替换变量模板
你每次可以这样发给 AI
```md
请按“账号贡献站使用教程模板”帮我写一份 Markdown 教程。
本次教程主题:
[这里写主题]
目标读者:
[例如:第一次使用贡献页的用户]
希望教程覆盖的内容:
- [内容 1]
- [内容 2]
- [内容 3]
必须出现的步骤:
1. [步骤 1]
2. [步骤 2]
3. [步骤 3]
希望补充的常见问题:
- [问题 1]
- [问题 2]
```
---
## 11. 结果验收标准
生成后的教程 Markdown 必须满足:
- 复制到管理端后能直接显示
- 标题和章节清楚
- 教程导航能识别出标题结构
- 步骤顺序合理
- 没有 HTML
- 没有明显 AI 套话
- 普通用户能直接照着做
+218
View File
@@ -0,0 +1,218 @@
(function attachGoPayUtils(root, factory) {
root.GoPayUtils = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createGoPayUtils() {
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
const DEFAULT_GPC_HELPER_API_URL = 'https://gopay.hwork.pro';
function normalizePlusPaymentMethod(value = '') {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === PLUS_PAYMENT_METHOD_GPC_HELPER) {
return PLUS_PAYMENT_METHOD_GPC_HELPER;
}
return normalized === PLUS_PAYMENT_METHOD_GOPAY ? PLUS_PAYMENT_METHOD_GOPAY : PLUS_PAYMENT_METHOD_PAYPAL;
}
const DEFAULT_GOPAY_COUNTRY_CODE = '+86';
function normalizeGoPayCountryCode(value = '') {
const normalized = String(value || '').trim().replace(/[^\d+]/g, '');
const digits = normalized.replace(/\D/g, '');
return digits ? `+${digits}` : DEFAULT_GOPAY_COUNTRY_CODE;
}
function normalizeGoPayPhone(value = '') {
return String(value || '').trim().replace(/[^\d+]/g, '');
}
function normalizeGoPayPhoneForCountry(value = '', countryCode = DEFAULT_GOPAY_COUNTRY_CODE) {
const normalizedPhone = normalizeGoPayPhone(value);
const normalizedCountryCode = normalizeGoPayCountryCode(countryCode);
const countryDigits = normalizedCountryCode.replace(/\D/g, '');
let nationalNumber = normalizedPhone.replace(/\D/g, '');
if (countryDigits && nationalNumber.startsWith(countryDigits)) {
nationalNumber = nationalNumber.slice(countryDigits.length);
}
return nationalNumber;
}
function normalizeGoPayPin(value = '') {
return String(value || '').trim().replace(/[^\d]/g, '');
}
function normalizeGoPayOtp(value = '') {
return String(value || '').trim().replace(/[^\d]/g, '');
}
function normalizeGpcHelperBaseUrl(apiUrl = '') {
let normalized = String(apiUrl || DEFAULT_GPC_HELPER_API_URL).trim();
if (!normalized) {
return DEFAULT_GPC_HELPER_API_URL;
}
normalized = normalized.replace(/\/+$/g, '');
normalized = normalized.replace(/\/api\/checkout\/start$/i, '');
normalized = normalized.replace(/\/api\/gopay\/(?:otp|pin)$/i, '');
normalized = normalized.replace(/\/api\/card\/balance(?:\?.*)?$/i, '');
return normalized || DEFAULT_GPC_HELPER_API_URL;
}
function buildGpcHelperApiUrl(apiUrl = '', path = '') {
const baseUrl = normalizeGpcHelperBaseUrl(apiUrl);
if (!baseUrl) {
return '';
}
const normalizedPath = String(path || '').startsWith('/') ? String(path || '') : `/${String(path || '')}`;
return `${baseUrl}${normalizedPath}`;
}
function buildGpcCardBalanceUrl(apiUrl = '', cardKey = '') {
const endpoint = buildGpcHelperApiUrl(apiUrl, '/api/card/balance');
if (!endpoint) {
return '';
}
return `${endpoint}?card_key=${encodeURIComponent(String(cardKey || '').trim())}`;
}
function extractGpcResponseErrorDetail(payload = {}, status = 0) {
if (!payload || typeof payload !== 'object') {
return status ? `HTTP ${status}` : '未知错误';
}
const payloadText = JSON.stringify(payload).toLowerCase();
if (/account\s+already\s+linked/i.test(payloadText)) {
return 'GOPAY已经绑了订阅,需要手动解绑';
}
const direct = payload.detail
?? payload.message
?? payload.error
?? payload.error_description
?? payload.reason;
if (direct !== undefined && direct !== null && String(direct).trim()) {
const directText = String(direct).trim();
return /account\s+already\s+linked/i.test(directText)
? 'GOPAY已经绑了订阅,需要手动解绑'
: directText;
}
const errorMessages = payload.error_messages ?? payload.errorMessages;
if (Array.isArray(errorMessages) && errorMessages.length > 0) {
const firstMessage = String(errorMessages[0] || '').trim();
if (/account\s+already\s+linked/i.test(firstMessage)) {
return 'GOPAY已经绑了订阅,需要手动解绑';
}
if (firstMessage) {
return firstMessage;
}
}
const errors = payload.errors;
if (Array.isArray(errors) && errors.length > 0) {
const first = errors[0];
if (typeof first === 'string') {
return first.trim() || (status ? `HTTP ${status}` : '未知错误');
}
if (first && typeof first === 'object') {
const field = Array.isArray(first.loc) ? first.loc.join('.') : String(first.field || first.path || '').trim();
const message = String(first.msg || first.message || first.error || '').trim();
return [field, message].filter(Boolean).join(': ') || JSON.stringify(first);
}
}
return status ? `HTTP ${status}` : '未知错误';
}
function buildGpcOtpPayload(input = {}) {
const payload = {
reference_id: String(input.reference_id ?? input.referenceId ?? '').trim(),
otp: normalizeGoPayOtp(input.otp ?? input.code ?? ''),
card_key: String(input.card_key ?? input.cardKey ?? '').trim(),
};
const flowId = String(input.flow_id ?? input.flowId ?? '').trim();
const gopayGuid = String(input.gopay_guid ?? input.gopayGuid ?? '').trim();
const redirectUrl = String(input.redirect_url ?? input.redirectUrl ?? '').trim();
if (flowId) payload.flow_id = flowId;
if (gopayGuid) payload.gopay_guid = gopayGuid;
if (redirectUrl) payload.redirect_url = redirectUrl;
return payload;
}
function buildGpcOtpRetryPayload(input = {}) {
const payload = buildGpcOtpPayload(input);
return { ...payload, code: payload.otp };
}
function buildGpcPinPayload(input = {}) {
const payload = {
reference_id: String(input.reference_id ?? input.referenceId ?? '').trim(),
challenge_id: String(input.challenge_id ?? input.challengeId ?? '').trim(),
gopay_guid: String(input.gopay_guid ?? input.gopayGuid ?? '').trim(),
pin: normalizeGoPayPin(input.pin ?? ''),
card_key: String(input.card_key ?? input.cardKey ?? '').trim(),
};
const flowId = String(input.flow_id ?? input.flowId ?? '').trim();
const redirectUrl = String(input.redirect_url ?? input.redirectUrl ?? '').trim();
if (flowId) payload.flow_id = flowId;
if (redirectUrl) payload.redirect_url = redirectUrl;
return payload;
}
function buildGpcPinRetryPayload(input = {}) {
const payload = buildGpcPinPayload(input);
return { ...payload, challengeId: payload.challenge_id };
}
function formatGpcBalancePayload(payload = {}) {
if (!payload || typeof payload !== 'object') {
return '';
}
const candidates = [
payload.remaining_uses,
payload.remainingUses,
payload.balance,
payload.remaining,
payload.uses,
payload.available_uses,
payload.availableUses,
];
const firstValue = candidates.find((value) => value !== undefined && value !== null && String(value).trim() !== '');
const status = String(payload.card_status || payload.cardStatus || payload.status || '').trim();
const flowId = String(payload.flow_id || payload.flowId || '').trim();
const parts = [];
if (firstValue !== undefined) {
parts.push(`余额 ${firstValue}`);
}
if (status) {
parts.push(`状态 ${status}`);
}
if (flowId) {
parts.push(`flow_id ${flowId}`);
}
return parts.join('');
}
return {
DEFAULT_GOPAY_COUNTRY_CODE,
DEFAULT_GPC_HELPER_API_URL,
PLUS_PAYMENT_METHOD_GPC_HELPER,
PLUS_PAYMENT_METHOD_GOPAY,
PLUS_PAYMENT_METHOD_PAYPAL,
buildGpcCardBalanceUrl,
buildGpcHelperApiUrl,
buildGpcOtpPayload,
buildGpcOtpRetryPayload,
buildGpcPinPayload,
buildGpcPinRetryPayload,
extractGpcResponseErrorDetail,
formatGpcBalancePayload,
normalizeGpcHelperBaseUrl,
normalizeGoPayCountryCode,
normalizeGoPayPhone,
normalizeGoPayPhoneForCountry,
normalizeGoPayOtp,
normalizeGoPayPin,
normalizePlusPaymentMethod,
};
});
+2 -1
View File
@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "codex-oauth-automation-extension",
"version": "4.0",
"version_name": "Ultra4.0",
"version_name": "Ultra6.0",
"description": "用于自动执行多步骤 OAuth 注册流程",
"permissions": [
"sidePanel",
@@ -51,6 +51,7 @@
"content/activation-utils.js",
"content/utils.js",
"content/auth-page-recovery.js",
"content/phone-country-utils.js",
"content/phone-auth.js",
"content/signup-page.js"
],
+1
View File
@@ -1,5 +1,6 @@
{
"name": "codex-oauth-automation-extension",
"license": "MIT",
"private": true,
"scripts": {
"test": "node --test tests/*.test.js"
+901
View File
@@ -0,0 +1,901 @@
// phone-sms/providers/five-sim.js — 5sim 接码平台适配层
(function attachFiveSimSmsProvider(root, factory) {
root.PhoneSmsFiveSimProvider = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createFiveSimSmsProviderModule() {
const PROVIDER_ID = '5sim';
const DEFAULT_BASE_URL = 'https://5sim.net';
const DEFAULT_PRODUCT = 'openai';
const DEFAULT_OPERATOR = 'any';
const DEFAULT_COUNTRY_ID = 'vietnam';
const DEFAULT_COUNTRY_LABEL = '越南 (Vietnam)';
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
const DEFAULT_MAX_USES = 3;
const FIVE_SIM_RATE_LIMIT_ERROR_PREFIX = 'FIVE_SIM_RATE_LIMIT::';
const MAX_PRICE_CANDIDATES = 8;
const SUPPORTED_COUNTRY_ITEMS = Object.freeze([
{ id: 'indonesia', label: '印度尼西亚 (Indonesia)' },
{ id: 'thailand', label: '泰国 (Thailand)' },
{ id: 'england', label: '英国 (England)' },
{ id: 'usa', label: '美国 (United States)' },
{ id: 'japan', label: '日本 (Japan)' },
{ id: 'germany', label: '德国 (Germany)' },
{ id: 'vietnam', label: '越南 (Vietnam)' },
]);
const SUPPORTED_COUNTRY_ID_SET = new Set(SUPPORTED_COUNTRY_ITEMS.map((item) => item.id));
const COUNTRY_CN_BY_ID = Object.freeze({
afghanistan: '阿富汗',
albania: '阿尔巴尼亚',
algeria: '阿尔及利亚',
angola: '安哥拉',
argentina: '阿根廷',
armenia: '亚美尼亚',
australia: '澳大利亚',
austria: '奥地利',
azerbaijan: '阿塞拜疆',
bahamas: '巴哈马',
bahrain: '巴林',
bangladesh: '孟加拉国',
belarus: '白俄罗斯',
belgium: '比利时',
bolivia: '玻利维亚',
bosnia: '波黑',
brazil: '巴西',
bulgaria: '保加利亚',
cambodia: '柬埔寨',
cameroon: '喀麦隆',
canada: '加拿大',
chile: '智利',
china: '中国',
colombia: '哥伦比亚',
croatia: '克罗地亚',
cyprus: '塞浦路斯',
czech: '捷克',
denmark: '丹麦',
egypt: '埃及',
england: '英国',
estonia: '爱沙尼亚',
ethiopia: '埃塞俄比亚',
finland: '芬兰',
france: '法国',
georgia: '格鲁吉亚',
germany: '德国',
ghana: '加纳',
greece: '希腊',
hongkong: '中国香港',
hungary: '匈牙利',
india: '印度',
indonesia: '印度尼西亚',
ireland: '爱尔兰',
israel: '以色列',
italy: '意大利',
japan: '日本',
jordan: '约旦',
kazakhstan: '哈萨克斯坦',
kenya: '肯尼亚',
kyrgyzstan: '吉尔吉斯斯坦',
laos: '老挝',
latvia: '拉脱维亚',
lithuania: '立陶宛',
malaysia: '马来西亚',
mexico: '墨西哥',
moldova: '摩尔多瓦',
morocco: '摩洛哥',
myanmar: '缅甸',
nepal: '尼泊尔',
netherlands: '荷兰',
newzealand: '新西兰',
nigeria: '尼日利亚',
norway: '挪威',
pakistan: '巴基斯坦',
paraguay: '巴拉圭',
peru: '秘鲁',
philippines: '菲律宾',
poland: '波兰',
portugal: '葡萄牙',
romania: '罗马尼亚',
russia: '俄罗斯',
saudiarabia: '沙特阿拉伯',
serbia: '塞尔维亚',
singapore: '新加坡',
slovakia: '斯洛伐克',
slovenia: '斯洛文尼亚',
southafrica: '南非',
spain: '西班牙',
srilanka: '斯里兰卡',
sweden: '瑞典',
switzerland: '瑞士',
taiwan: '中国台湾',
tajikistan: '塔吉克斯坦',
tanzania: '坦桑尼亚',
thailand: '泰国',
turkey: '土耳其',
ukraine: '乌克兰',
uruguay: '乌拉圭',
usa: '美国',
uzbekistan: '乌兹别克斯坦',
venezuela: '委内瑞拉',
vietnam: '越南',
});
function normalizeFiveSimCountryId(value, fallback = DEFAULT_COUNTRY_ID) {
const normalized = String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '');
if (normalized) {
return normalized;
}
const fallbackSource = fallback === undefined || fallback === null ? DEFAULT_COUNTRY_ID : fallback;
const normalizedFallback = String(fallbackSource).trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '');
if (!normalizedFallback) {
return '';
}
return normalizedFallback || DEFAULT_COUNTRY_ID;
}
function getCountryIdFromPayload(record = {}, fallback = DEFAULT_COUNTRY_ID) {
if (record?.country && typeof record.country === 'object' && !Array.isArray(record.country)) {
return normalizeFiveSimCountryId(record.country.name || record.country.id || record.country.slug, fallback);
}
return normalizeFiveSimCountryId(record.countryId ?? record.country, fallback);
}
function getCountryLabelFromPayload(record = {}, fallbackLabel = DEFAULT_COUNTRY_LABEL, fallbackId = DEFAULT_COUNTRY_ID) {
if (record?.country && typeof record.country === 'object' && !Array.isArray(record.country)) {
const countryId = normalizeFiveSimCountryId(record.country.name || record.country.id || record.country.slug || fallbackId, fallbackId);
return formatFiveSimCountryLabel(countryId, record.country.text_en || record.country.label || countryId, fallbackLabel || fallbackId);
}
const countryId = normalizeFiveSimCountryId(record.countryId ?? record.country ?? fallbackId, fallbackId);
return normalizeFiveSimCountryLabel(record.countryLabel, formatFiveSimCountryLabel(countryId, countryId, fallbackLabel || fallbackId));
}
function normalizeFiveSimCountryLabel(value = '', fallback = DEFAULT_COUNTRY_LABEL) {
return String(value || '').trim() || fallback;
}
function formatFiveSimCountryLabel(id = '', englishValue = '', fallback = DEFAULT_COUNTRY_LABEL) {
const countryId = normalizeFiveSimCountryId(id, '');
const english = normalizeFiveSimCountryLabel(englishValue || countryId || fallback, fallback);
const chinese = COUNTRY_CN_BY_ID[countryId] || '';
if (chinese && english) {
if (String(english).includes(chinese)) {
return english;
}
if (chinese.toLowerCase() !== english.toLowerCase()) {
return `${chinese} (${english})`;
}
}
return chinese || english;
}
function normalizeFiveSimOperator(value = '') {
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '') || DEFAULT_OPERATOR;
}
function normalizeFiveSimMaxPrice(value = '') {
const rawValue = String(value ?? '').trim();
if (!rawValue) {
return '';
}
const numeric = Number(rawValue);
if (!Number.isFinite(numeric) || numeric <= 0) {
return '';
}
return String(Math.round(numeric * 10000) / 10000);
}
function normalizeFiveSimCountryFallback(value = []) {
const source = Array.isArray(value)
? value
: String(value || '')
.split(/[\r\n,;]+/)
.map((entry) => String(entry || '').trim())
.filter(Boolean);
const seen = new Set();
const normalized = [];
for (const entry of source) {
let id = '';
let label = '';
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
id = normalizeFiveSimCountryId(entry.id ?? entry.countryId ?? entry.slug, '');
label = String((entry.label ?? entry.countryLabel ?? entry.text_en ?? entry.name) || '').trim();
} else {
const text = String(entry || '').trim();
const structured = text.match(/^([a-z0-9_-]+)\s*(?:[:|/-]\s*(.+))?$/i);
id = normalizeFiveSimCountryId(structured?.[1] || text, '');
label = String(structured?.[2] || '').trim();
}
if (!id || seen.has(id)) {
continue;
}
seen.add(id);
normalized.push({
id,
label: label || formatFiveSimCountryLabel(id, id, id),
});
if (normalized.length >= 20) {
break;
}
}
return normalized;
}
function normalizePrice(value) {
const price = Number(value);
if (!Number.isFinite(price) || price < 0) {
return null;
}
return price;
}
function buildSortedUniquePriceCandidates(values = []) {
return Array.from(
new Set(
values
.map((value) => normalizePrice(value))
.filter((value) => value !== null)
.map((value) => Math.round(value * 10000) / 10000)
)
)
.sort((left, right) => left - right)
.slice(0, MAX_PRICE_CANDIDATES);
}
function describePayload(raw) {
if (typeof raw === 'string') {
return raw.trim();
}
if (raw && typeof raw === 'object') {
const direct = String(raw.message || raw.msg || raw.error || raw.title || raw.status || '').trim();
if (direct) {
return direct;
}
try {
return JSON.stringify(raw);
} catch {
return String(raw);
}
}
return String(raw || '').trim();
}
function parsePayload(text) {
const trimmed = String(text || '').trim();
if (!trimmed) {
return '';
}
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try {
return JSON.parse(trimmed);
} catch {
return trimmed;
}
}
return trimmed;
}
function normalizeBaseUrl(value = '') {
const trimmed = String(value || '').trim() || DEFAULT_BASE_URL;
try {
const url = new URL(trimmed);
return `${url.origin}${url.pathname.replace(/\/+$/, '')}`;
} catch {
return DEFAULT_BASE_URL;
}
}
function buildUrl(config = {}, path = '', query = {}) {
const url = new URL(path, `${normalizeBaseUrl(config.baseUrl)}/`);
Object.entries(query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') {
return;
}
url.searchParams.set(key, String(value));
});
return url.toString();
}
async function fetchJson(config = {}, path = '', options = {}) {
const { query = {}, actionLabel = '5sim 请求', requireAuth = true } = options;
const token = String(config.apiKey || '').trim();
if (requireAuth && !token) {
throw new Error('缺少 5sim API Key,请先在侧边栏接码设置中填写并保存。');
}
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timeoutId = controller
? setTimeout(() => controller.abort(), Number(config.requestTimeoutMs) || DEFAULT_REQUEST_TIMEOUT_MS)
: null;
try {
const response = await config.fetchImpl(buildUrl(config, path, query), {
method: 'GET',
signal: controller?.signal,
headers: {
Accept: 'application/json',
...(requireAuth ? { Authorization: `Bearer ${token}` } : {}),
},
});
const text = await response.text();
const payload = parsePayload(text);
if (!response.ok) {
const error = new Error(`${actionLabel}失败:${describePayload(payload) || response.status}`);
error.payload = payload;
error.status = response.status;
throw error;
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`${actionLabel}超时。`);
}
throw error;
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
}
}
}
function resolveConfig(state = {}, deps = {}) {
return {
apiKey: String(state.fiveSimApiKey || '').trim(),
baseUrl: DEFAULT_BASE_URL,
fetchImpl: deps.fetchImpl,
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
};
}
function resolveCountryConfig(state = {}) {
const rawCountryId = normalizeFiveSimCountryId(state.fiveSimCountryId, '');
const id = rawCountryId || DEFAULT_COUNTRY_ID;
return {
id,
label: formatFiveSimCountryLabel(id, state.fiveSimCountryLabel || id, DEFAULT_COUNTRY_LABEL),
};
}
function resolveCountryCandidates(state = {}) {
const primary = resolveCountryConfig(state);
const fallbackList = normalizeFiveSimCountryFallback(state.fiveSimCountryFallback);
const seen = new Set([primary.id]);
const candidates = [primary];
fallbackList.forEach((entry) => {
const nextId = normalizeFiveSimCountryId(entry.id, '');
if (!nextId || seen.has(nextId)) {
return;
}
seen.add(nextId);
candidates.push({
id: nextId,
label: formatFiveSimCountryLabel(nextId, entry.label || nextId, nextId),
});
});
return candidates;
}
async function fetchBalance(state = {}, deps = {}) {
const config = resolveConfig(state, deps);
const payload = await fetchJson(config, '/v1/user/profile', {
actionLabel: '5sim 查询余额',
});
return {
balance: Number(payload?.balance),
frozenBalance: Number(payload?.frozen_balance),
rating: Number(payload?.rating),
raw: payload,
};
}
async function fetchCountries(_state = {}, deps = {}) {
const config = {
baseUrl: DEFAULT_BASE_URL,
fetchImpl: deps.fetchImpl,
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
};
const payload = await fetchJson(config, '/v1/guest/countries', {
actionLabel: '5sim 查询国家列表',
requireAuth: false,
});
return Object.entries(payload || {})
.map(([slug, entry]) => {
const id = normalizeFiveSimCountryId(slug, '');
if (!id) {
return null;
}
return {
id,
label: formatFiveSimCountryLabel(id, entry?.text_en || slug, slug),
searchText: [
slug,
formatFiveSimCountryLabel(id, entry?.text_en || slug, slug),
entry?.text_en,
entry?.text_ru,
Object.keys(entry?.iso || {}).join(' '),
Object.keys(entry?.prefix || {}).join(' '),
].filter(Boolean).join(' '),
};
})
.filter(Boolean)
.sort((left, right) => String(left.label || '').localeCompare(String(right.label || '')));
}
function collectPriceEntries(payload, entries = []) {
if (Array.isArray(payload)) {
payload.forEach((entry) => collectPriceEntries(entry, entries));
return entries;
}
if (!payload || typeof payload !== 'object') {
return entries;
}
const cost = normalizePrice(payload.cost ?? payload.Price ?? payload.price);
if (cost !== null) {
const count = Number(payload.count ?? payload.Qty ?? payload.qty);
entries.push({
cost,
count: Number.isFinite(count) ? count : 0,
inStock: !Number.isFinite(count) || count > 0,
});
}
Object.values(payload).forEach((entry) => collectPriceEntries(entry, entries));
return entries;
}
async function fetchPrices(state = {}, countryConfig = resolveCountryConfig(state), deps = {}) {
const config = {
baseUrl: DEFAULT_BASE_URL,
fetchImpl: deps.fetchImpl,
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
};
return fetchJson(config, '/v1/guest/prices', {
query: {
country: normalizeFiveSimCountryId(countryConfig?.id),
product: DEFAULT_PRODUCT,
},
actionLabel: '5sim 查询价格',
requireAuth: false,
});
}
async function fetchProducts(state = {}, countryConfig = resolveCountryConfig(state), deps = {}) {
const config = {
baseUrl: DEFAULT_BASE_URL,
fetchImpl: deps.fetchImpl,
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
};
const operator = normalizeFiveSimOperator(state.fiveSimOperator);
return fetchJson(
config,
`/v1/guest/products/${encodeURIComponent(normalizeFiveSimCountryId(countryConfig?.id))}/${encodeURIComponent(operator)}`,
{
actionLabel: '5sim 查询产品价格',
requireAuth: false,
}
);
}
async function resolvePricePlan(state = {}, countryConfig = resolveCountryConfig(state), deps = {}) {
const userLimitText = normalizeFiveSimMaxPrice(state.fiveSimMaxPrice);
const userLimit = userLimitText ? Number(userLimitText) : null;
let priceCandidates = [];
try {
const productsPayload = await fetchProducts(state, countryConfig, deps);
const openaiProduct = productsPayload?.[DEFAULT_PRODUCT];
const productPrice = normalizePrice(openaiProduct?.Price ?? openaiProduct?.price ?? openaiProduct?.cost);
const productQty = Number(openaiProduct?.Qty ?? openaiProduct?.qty ?? openaiProduct?.count);
if (productPrice !== null && (!Number.isFinite(productQty) || productQty > 0)) {
priceCandidates.push(productPrice);
}
} catch (_) {
// Products endpoint is only used to find the buy-compatible operator price.
}
try {
const payload = await fetchPrices(state, countryConfig, deps);
priceCandidates = [
...priceCandidates,
...buildSortedUniquePriceCandidates(
collectPriceEntries(payload, [])
.filter((entry) => entry.inStock)
.map((entry) => entry.cost)
),
];
} catch (_) {
// Best-effort lookup. Purchase can still be attempted without a catalog price.
}
priceCandidates = buildSortedUniquePriceCandidates(priceCandidates);
const minCatalogPrice = priceCandidates.length > 0 ? priceCandidates[0] : null;
if (userLimit !== null) {
const bounded = priceCandidates.filter((price) => price <= userLimit);
return {
prices: bounded.length > 0 ? [userLimit, ...bounded.filter((price) => price !== userLimit)] : [userLimit],
userLimit,
minCatalogPrice,
};
}
if (priceCandidates.length > 0) {
return { prices: priceCandidates, userLimit: null, minCatalogPrice };
}
return { prices: [null], userLimit: null, minCatalogPrice: null };
}
function normalizeActivation(record, fallback = {}) {
if (!record || typeof record !== 'object' || Array.isArray(record)) {
return null;
}
const activationId = String(record.activationId ?? record.id ?? '').trim();
const phoneNumber = String(record.phoneNumber ?? record.phone ?? '').trim();
if (!activationId || !phoneNumber) {
return null;
}
const countryId = getCountryIdFromPayload(record, fallback.countryId || DEFAULT_COUNTRY_ID);
const countryLabel = getCountryLabelFromPayload(record, fallback.countryLabel || countryId, countryId);
return {
activationId,
phoneNumber,
provider: PROVIDER_ID,
serviceCode: DEFAULT_PRODUCT,
countryId,
countryLabel,
successfulUses: Math.max(0, Math.floor(Number(record.successfulUses) || 0)),
maxUses: Math.max(1, Math.floor(Number(record.maxUses) || DEFAULT_MAX_USES)),
operator: normalizeFiveSimOperator(record.operator || fallback.operator),
...(record.price !== undefined ? { price: Number(record.price) } : {}),
...(record.status ? { status: String(record.status) } : {}),
};
}
function isNoNumbersPayload(payloadOrMessage) {
const text = describePayload(payloadOrMessage);
return /no\s+free\s+phones|no\s+numbers|not\s+found/i.test(text);
}
function isRateLimitPayload(payloadOrMessage) {
const text = describePayload(payloadOrMessage);
return /rate\s*limit|too\s*many\s*requests|request\s*limit|429/i.test(text);
}
function buildFiveSimRateLimitError(details = []) {
const suffix = Array.isArray(details) && details.length
? `${details.join(' | ')}`
: '。';
return new Error(`${FIVE_SIM_RATE_LIMIT_ERROR_PREFIX}5sim 购买接口触发限流,请稍后再试${suffix}`);
}
function isTerminalError(payloadOrMessage) {
const text = describePayload(payloadOrMessage);
return /not\s+enough\s+(?:user\s+)?balance|not\s+enough\s+rating|unauthorized|invalid\s+token|banned|bad\s+(?:country|operator)|no\s+product|server\s+offline/i.test(text);
}
function assertMaxPriceCompatibleWithOperator(state = {}) {
const maxPrice = normalizeFiveSimMaxPrice(state.fiveSimMaxPrice);
const operator = normalizeFiveSimOperator(state.fiveSimOperator);
if (maxPrice && operator !== DEFAULT_OPERATOR) {
throw new Error('5sim maxPrice only works when operator is "any"; clear the price limit or switch operator to any before buying a number.');
}
}
async function buyActivationWithPrice(state = {}, countryConfig, maxPrice, deps = {}) {
const config = resolveConfig(state, deps);
const operator = normalizeFiveSimOperator(state.fiveSimOperator);
const query = {};
if (maxPrice !== null && maxPrice !== undefined) {
query.maxPrice = maxPrice;
}
if (state.fiveSimReuseEnabled !== false) {
query.reuse = 1;
}
const payload = await fetchJson(
config,
`/v1/user/buy/activation/${encodeURIComponent(normalizeFiveSimCountryId(countryConfig.id))}/${encodeURIComponent(operator)}/${DEFAULT_PRODUCT}`,
{
query,
actionLabel: '5sim 购买手机号',
}
);
const activation = normalizeActivation(payload, {
countryId: countryConfig.id,
countryLabel: countryConfig.label,
operator,
});
if (!activation) {
const error = new Error(`5sim 购买手机号返回不可用响应:${describePayload(payload) || '空响应'}`);
error.payload = payload;
throw error;
}
return activation;
}
async function requestActivation(state = {}, options = {}, deps = {}) {
assertMaxPriceCompatibleWithOperator(state);
const allCountryCandidates = resolveCountryCandidates(state);
const blockedCountryIds = new Set(
(Array.isArray(options?.blockedCountryIds) ? options.blockedCountryIds : [])
.map((value) => normalizeFiveSimCountryId(value, ''))
.filter(Boolean)
);
let countryCandidates = allCountryCandidates.filter((entry) => !blockedCountryIds.has(normalizeFiveSimCountryId(entry.id, '')));
if (!countryCandidates.length) {
countryCandidates = allCountryCandidates;
if (blockedCountryIds.size && typeof deps.addLog === 'function') {
await deps.addLog(
'步骤 9:已选 5sim 国家均达到临时收码失败跳过阈值,本轮解除跳过并重新尝试。',
'warn'
);
}
}
const acquirePriority = String(state?.heroSmsAcquirePriority || 'country').trim().toLowerCase() === 'price' ? 'price' : 'country';
const countryAttempts = countryCandidates.map((countryConfig, index) => ({
index,
countryConfig,
pricePlan: null,
orderingPrice: Number.POSITIVE_INFINITY,
}));
if (acquirePriority === 'price') {
for (const attempt of countryAttempts) {
const pricePlan = await resolvePricePlan(state, attempt.countryConfig, deps);
const numericPrices = Array.isArray(pricePlan?.prices)
? pricePlan.prices.map(Number).filter((value) => Number.isFinite(value) && value >= 0)
: [];
const minCandidatePrice = numericPrices.length ? Math.min(...numericPrices) : null;
const cappedByUserLimit = (
pricePlan?.userLimit !== null
&& pricePlan?.userLimit !== undefined
&& pricePlan?.minCatalogPrice !== null
&& pricePlan?.minCatalogPrice !== undefined
&& Number(pricePlan.minCatalogPrice) > Number(pricePlan.userLimit)
);
attempt.pricePlan = pricePlan;
attempt.orderingPrice = cappedByUserLimit
? Number.POSITIVE_INFINITY
: (minCandidatePrice !== null ? minCandidatePrice : Number.POSITIVE_INFINITY);
}
countryAttempts.sort((left, right) => (
left.orderingPrice !== right.orderingPrice
? left.orderingPrice - right.orderingPrice
: left.index - right.index
));
}
const noNumbersByCountry = [];
const rateLimitByCountry = [];
let lastError = null;
let lastFailureText = '';
let sawOnlyRetryableFailures = true;
for (const attempt of countryAttempts) {
const countryConfig = attempt.countryConfig;
const countryFailures = [];
const pricePlan = attempt.pricePlan || await resolvePricePlan(state, countryConfig, deps);
for (const maxPrice of pricePlan.prices) {
try {
const activation = await buyActivationWithPrice(state, countryConfig, maxPrice, deps);
if (activation) {
return activation;
}
lastFailureText = '空响应';
} catch (error) {
const payloadOrMessage = error?.payload || error?.message;
if (isTerminalError(payloadOrMessage)) {
throw new Error(`5sim 购买手机号失败:${describePayload(payloadOrMessage) || '空响应'}`);
}
const failureText = describePayload(payloadOrMessage) || lastFailureText;
if (isRateLimitPayload(payloadOrMessage)) {
lastFailureText = failureText;
countryFailures.push(failureText || 'rate limit');
continue;
}
if (isNoNumbersPayload(payloadOrMessage)) {
lastFailureText = failureText;
countryFailures.push(failureText || '无可用号码');
continue;
}
sawOnlyRetryableFailures = false;
lastError = error;
lastFailureText = failureText;
}
}
const countryFailureText = countryFailures.length
? Array.from(new Set(countryFailures)).join(', ')
: (lastFailureText || '无可用号码');
if (countryFailures.some((text) => isRateLimitPayload(text))) {
rateLimitByCountry.push(`${countryConfig.label}: ${countryFailureText}`);
} else {
noNumbersByCountry.push(`${countryConfig.label}: ${countryFailureText}`);
}
}
if (rateLimitByCountry.length) {
throw buildFiveSimRateLimitError(rateLimitByCountry);
}
if (noNumbersByCountry.length && sawOnlyRetryableFailures) {
throw new Error(`5sim 已尝试 ${countryCandidates.length} 个候选国家,均无可用号码:${noNumbersByCountry.join(' | ')}`);
}
if (lastError) {
throw lastError;
}
throw new Error(`5sim 获取手机号失败,最后状态:${lastFailureText || '未知'}`);
}
async function reuseActivation(state = {}, activation, deps = {}) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('缺少可复用的 5sim 手机号订单。');
}
const phoneDigits = String(normalizedActivation.phoneNumber || '').replace(/\D+/g, '');
if (!phoneDigits) {
throw new Error('可复用的 5sim 手机号无效。');
}
const config = resolveConfig(state, deps);
const numberWithoutPlus = String(normalizedActivation.phoneNumber || '')
.replace(/^\+/, '')
.replace(/[^0-9]+/g, '');
const payload = await fetchJson(config, `/v1/user/reuse/${DEFAULT_PRODUCT}/${numberWithoutPlus || phoneDigits}`, {
actionLabel: '5sim 复用手机号',
});
return normalizeActivation(payload, normalizedActivation);
}
async function finishActivation(state = {}, activation, deps = {}) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) return '';
const config = resolveConfig(state, deps);
const payload = await fetchJson(config, `/v1/user/finish/${encodeURIComponent(normalizedActivation.activationId)}`, {
actionLabel: '5sim 完成订单',
});
return describePayload(payload);
}
async function cancelActivation(state = {}, activation, deps = {}) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) return '';
const config = resolveConfig(state, deps);
const payload = await fetchJson(config, `/v1/user/cancel/${encodeURIComponent(normalizedActivation.activationId)}`, {
actionLabel: '5sim 取消订单',
});
return describePayload(payload);
}
async function banActivation(state = {}, activation, deps = {}) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) return '';
const config = resolveConfig(state, deps);
const payload = await fetchJson(config, `/v1/user/ban/${encodeURIComponent(normalizedActivation.activationId)}`, {
actionLabel: '5sim 拉黑号码',
});
return describePayload(payload);
}
function extractVerificationCode(rawCodeOrText) {
const trimmed = String(rawCodeOrText || '').trim();
if (!trimmed) {
return '';
}
const digitMatch = trimmed.match(/\b(\d{4,8})\b/);
return digitMatch?.[1] || trimmed;
}
function extractCodeFromOrder(payload) {
const smsList = Array.isArray(payload?.sms) ? payload.sms : [];
for (let index = smsList.length - 1; index >= 0; index -= 1) {
const message = smsList[index] || {};
const code = extractVerificationCode(message.code) || extractVerificationCode(message.text);
if (code) {
return code;
}
}
return '';
}
async function pollActivationCode(state = {}, activation, options = {}, deps = {}) {
const normalizedActivation = normalizeActivation(activation);
if (!normalizedActivation) {
throw new Error('缺少手机号接码订单。');
}
const config = resolveConfig(state, deps);
const timeoutMs = Math.max(1000, Number(options.timeoutMs) || 180000);
const intervalMs = Math.max(1000, Number(options.intervalMs) || 5000);
const maxRoundsRaw = Math.floor(Number(options.maxRounds));
const maxRounds = Number.isFinite(maxRoundsRaw) && maxRoundsRaw > 0 ? maxRoundsRaw : 0;
const start = Date.now();
let pollCount = 0;
let lastResponse = '';
while (Date.now() - start < timeoutMs) {
if (maxRounds > 0 && pollCount >= maxRounds) {
break;
}
deps.throwIfStopped?.();
const payload = await fetchJson(config, `/v1/user/check/${encodeURIComponent(normalizedActivation.activationId)}`, {
actionLabel: '5sim 查询验证码',
});
pollCount += 1;
lastResponse = describePayload(payload);
if (typeof options.onStatus === 'function') {
await options.onStatus({
activation: normalizedActivation,
elapsedMs: Date.now() - start,
pollCount,
statusText: String(payload?.status || lastResponse || '未知'),
timeoutMs,
});
}
const code = extractCodeFromOrder(payload);
if (code) {
return code;
}
const status = String(payload?.status || '').trim().toUpperCase();
if (['CANCELED', 'BANNED', 'FINISHED', 'TIMEOUT'].includes(status)) {
throw new Error(`5sim 查询验证码失败:订单状态 ${status}`);
}
await deps.sleepWithStop(intervalMs);
}
const suffix = lastResponse ? ` 5sim 最后状态:${lastResponse}` : '';
throw new Error(`PHONE_CODE_TIMEOUT::等待手机验证码超时。${suffix}`);
}
function createProvider(deps = {}) {
const providerDeps = {
fetchImpl: deps.fetchImpl,
sleepWithStop: deps.sleepWithStop,
throwIfStopped: deps.throwIfStopped,
addLog: deps.addLog,
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
};
return {
id: PROVIDER_ID,
label: '5sim',
defaultCountryId: DEFAULT_COUNTRY_ID,
defaultCountryLabel: DEFAULT_COUNTRY_LABEL,
supportedCountries: SUPPORTED_COUNTRY_ITEMS,
defaultProduct: DEFAULT_PRODUCT,
defaultOperator: DEFAULT_OPERATOR,
normalizeCountryId: normalizeFiveSimCountryId,
normalizeCountryLabel: normalizeFiveSimCountryLabel,
formatCountryLabel: formatFiveSimCountryLabel,
normalizeCountryFallback: normalizeFiveSimCountryFallback,
normalizeMaxPrice: normalizeFiveSimMaxPrice,
normalizeOperator: normalizeFiveSimOperator,
resolveCountryCandidates,
requestActivation: (state, options) => requestActivation(state, options, providerDeps),
reuseActivation: (state, activation) => reuseActivation(state, activation, providerDeps),
finishActivation: (state, activation) => finishActivation(state, activation, providerDeps),
cancelActivation: (state, activation) => cancelActivation(state, activation, providerDeps),
banActivation: (state, activation) => banActivation(state, activation, providerDeps),
pollActivationCode: (state, activation, options) => pollActivationCode(state, activation, options, providerDeps),
fetchBalance: (state) => fetchBalance(state, providerDeps),
fetchCountries: (state) => fetchCountries(state, providerDeps),
fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, providerDeps),
collectPriceEntries,
describePayload,
};
}
return {
PROVIDER_ID,
FIVE_SIM_RATE_LIMIT_ERROR_PREFIX,
DEFAULT_COUNTRY_ID,
DEFAULT_COUNTRY_LABEL,
DEFAULT_OPERATOR,
DEFAULT_PRODUCT,
SUPPORTED_COUNTRY_ITEMS,
createProvider,
normalizeFiveSimCountryFallback,
normalizeFiveSimCountryId,
normalizeFiveSimCountryLabel,
formatFiveSimCountryLabel,
normalizeFiveSimMaxPrice,
normalizeFiveSimOperator,
};
});
+213
View File
@@ -0,0 +1,213 @@
// phone-sms/providers/hero-sms.js — HeroSMS 接码平台适配层
(function attachHeroSmsProvider(root, factory) {
root.PhoneSmsHeroSmsProvider = factory();
})(typeof self !== 'undefined' ? self : globalThis, function createHeroSmsProviderModule() {
const PROVIDER_ID = 'hero-sms';
const DEFAULT_BASE_URL = 'https://hero-sms.com/stubs/handler_api.php';
const DEFAULT_SERVICE_CODE = 'dr';
const DEFAULT_SERVICE_LABEL = 'OpenAI';
const DEFAULT_COUNTRY_ID = 52;
const DEFAULT_COUNTRY_LABEL = 'Thailand';
const DEFAULT_REQUEST_TIMEOUT_MS = 20000;
function normalizeHeroSmsCountryId(value, fallback = DEFAULT_COUNTRY_ID) {
const parsed = Math.floor(Number(value));
if (Number.isFinite(parsed) && parsed > 0) return parsed;
const fallbackParsed = Math.floor(Number(fallback));
return Number.isFinite(fallbackParsed) && fallbackParsed > 0 ? fallbackParsed : DEFAULT_COUNTRY_ID;
}
function normalizeHeroSmsCountryLabel(value = '', fallback = DEFAULT_COUNTRY_LABEL) {
return String(value || '').trim() || fallback;
}
function normalizeHeroSmsMaxPrice(value = '') {
const rawValue = String(value ?? '').trim();
if (!rawValue) return '';
const numeric = Number(rawValue);
if (!Number.isFinite(numeric) || numeric <= 0) return '';
return String(Math.round(numeric * 10000) / 10000);
}
function normalizeHeroSmsCountryFallback(value = []) {
const source = Array.isArray(value)
? value
: String(value || '')
.split(/[\r\n,;]+/)
.map((entry) => String(entry || '').trim())
.filter(Boolean);
const seen = new Set();
const normalized = [];
for (const entry of source) {
let id = 0;
let label = '';
if (entry && typeof entry === 'object' && !Array.isArray(entry)) {
id = normalizeHeroSmsCountryId(entry.id ?? entry.countryId, 0);
label = String((entry.label ?? entry.countryLabel) || '').trim();
} else {
const text = String(entry || '').trim();
const structured = text.match(/^(\d+)\s*(?:[:|/-]\s*(.+))?$/);
id = normalizeHeroSmsCountryId(structured?.[1] || text, 0);
label = String(structured?.[2] || '').trim();
}
if (!id || seen.has(id)) continue;
seen.add(id);
normalized.push({ id, label: label || `Country #${id}` });
if (normalized.length >= 20) break;
}
return normalized;
}
function normalizeBaseUrl(value = '') {
const trimmed = String(value || '').trim() || DEFAULT_BASE_URL;
try {
return new URL(trimmed).toString();
} catch {
return DEFAULT_BASE_URL;
}
}
function buildUrl(config = {}, query = {}) {
const url = new URL(normalizeBaseUrl(config.baseUrl));
Object.entries(query || {}).forEach(([key, value]) => {
if (value === undefined || value === null || value === '') return;
url.searchParams.set(key, String(value));
});
return url.toString();
}
function parsePayload(text) {
const trimmed = String(text || '').trim();
if (!trimmed) return '';
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
try { return JSON.parse(trimmed); } catch { return trimmed; }
}
return trimmed;
}
function describePayload(raw) {
if (typeof raw === 'string') return raw.trim();
if (raw && typeof raw === 'object') {
const direct = String(raw.message || raw.msg || raw.error || raw.title || raw.status || '').trim();
if (direct) return direct;
try { return JSON.stringify(raw); } catch { return String(raw); }
}
return String(raw || '').trim();
}
function resolveConfig(state = {}, deps = {}) {
return {
apiKey: String(state.heroSmsApiKey || '').trim(),
baseUrl: state.heroSmsBaseUrl || DEFAULT_BASE_URL,
fetchImpl: deps.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null),
requestTimeoutMs: deps.requestTimeoutMs || DEFAULT_REQUEST_TIMEOUT_MS,
};
}
async function fetchPayload(config, query, actionLabel = 'HeroSMS request') {
if (query.api_key === undefined && config.apiKey) {
query = { api_key: config.apiKey, ...query };
}
if (!config.fetchImpl) {
throw new Error('HeroSMS fetch implementation is unavailable.');
}
const controller = typeof AbortController === 'function' ? new AbortController() : null;
const timeoutId = controller
? setTimeout(() => controller.abort(), Number(config.requestTimeoutMs) || DEFAULT_REQUEST_TIMEOUT_MS)
: null;
try {
const response = await config.fetchImpl(buildUrl(config, query), {
method: 'GET',
signal: controller?.signal,
});
const text = await response.text();
const payload = parsePayload(text);
if (!response.ok) {
const error = new Error(`${actionLabel} failed: ${describePayload(payload) || response.status}`);
error.payload = payload;
error.status = response.status;
throw error;
}
return payload;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`${actionLabel} timed out.`);
}
throw error;
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
function resolveCountryConfig(state = {}) {
return {
id: normalizeHeroSmsCountryId(state.heroSmsCountryId),
label: normalizeHeroSmsCountryLabel(state.heroSmsCountryLabel),
};
}
function resolveCountryCandidates(state = {}) {
const primary = resolveCountryConfig(state);
const seen = new Set([primary.id]);
const candidates = [primary];
normalizeHeroSmsCountryFallback(state.heroSmsCountryFallback).forEach((entry) => {
const id = normalizeHeroSmsCountryId(entry.id, 0);
if (!id || seen.has(id)) return;
seen.add(id);
candidates.push({ id, label: normalizeHeroSmsCountryLabel(entry.label, `Country #${id}`) });
});
return candidates;
}
async function fetchBalance(state = {}, deps = {}) {
const config = resolveConfig(state, deps);
if (!config.apiKey) {
throw new Error('HeroSMS API key is missing. Save it in the side panel before querying balance.');
}
const payload = await fetchPayload(config, { action: 'getBalance' }, 'HeroSMS getBalance');
const balance = Number(String(describePayload(payload)).replace(/^ACCESS_BALANCE:/i, '').trim());
return { balance, raw: payload };
}
async function fetchPrices(state = {}, countryConfig = resolveCountryConfig(state), deps = {}) {
const config = resolveConfig(state, deps);
return fetchPayload(config, {
action: 'getPrices',
service: DEFAULT_SERVICE_CODE,
country: normalizeHeroSmsCountryId(countryConfig?.id),
}, 'HeroSMS getPrices');
}
function createProvider(deps = {}) {
return {
id: PROVIDER_ID,
label: 'HeroSMS',
defaultCountryId: DEFAULT_COUNTRY_ID,
defaultCountryLabel: DEFAULT_COUNTRY_LABEL,
defaultProduct: DEFAULT_SERVICE_LABEL,
normalizeCountryId: normalizeHeroSmsCountryId,
normalizeCountryLabel: normalizeHeroSmsCountryLabel,
normalizeCountryFallback: normalizeHeroSmsCountryFallback,
normalizeMaxPrice: normalizeHeroSmsMaxPrice,
resolveCountryCandidates,
fetchBalance: (state) => fetchBalance(state, deps),
fetchPrices: (state, countryConfig) => fetchPrices(state, countryConfig, deps),
describePayload,
};
}
return {
PROVIDER_ID,
DEFAULT_BASE_URL,
DEFAULT_COUNTRY_ID,
DEFAULT_COUNTRY_LABEL,
DEFAULT_SERVICE_CODE,
DEFAULT_SERVICE_LABEL,
createProvider,
describePayload,
normalizeHeroSmsCountryFallback,
normalizeHeroSmsCountryId,
normalizeHeroSmsCountryLabel,
normalizeHeroSmsMaxPrice,
};
});
+43
View File
@@ -0,0 +1,43 @@
// phone-sms/providers/registry.js — 接码平台注册表
(function attachPhoneSmsProviderRegistry(root, factory) {
root.PhoneSmsProviderRegistry = factory(root);
})(typeof self !== 'undefined' ? self : globalThis, function createPhoneSmsProviderRegistry(root) {
const PROVIDER_HERO_SMS = 'hero-sms';
const PROVIDER_FIVE_SIM = '5sim';
const DEFAULT_PROVIDER = PROVIDER_HERO_SMS;
function normalizeProviderId(value = '') {
const normalized = String(value || '').trim().toLowerCase();
return normalized === PROVIDER_FIVE_SIM ? PROVIDER_FIVE_SIM : PROVIDER_HERO_SMS;
}
function getProviderModule(providerId = DEFAULT_PROVIDER) {
const normalized = normalizeProviderId(providerId);
if (normalized === PROVIDER_FIVE_SIM) {
return root.PhoneSmsFiveSimProvider || null;
}
return root.PhoneSmsHeroSmsProvider || null;
}
function createProvider(providerId = DEFAULT_PROVIDER, deps = {}) {
const module = getProviderModule(providerId);
if (!module || typeof module.createProvider !== 'function') {
throw new Error(`Phone SMS provider is not loaded: ${normalizeProviderId(providerId)}`);
}
return module.createProvider(deps);
}
function getProviderLabel(providerId = DEFAULT_PROVIDER) {
return normalizeProviderId(providerId) === PROVIDER_FIVE_SIM ? '5sim' : 'HeroSMS';
}
return {
PROVIDER_HERO_SMS,
PROVIDER_FIVE_SIM,
DEFAULT_PROVIDER,
normalizeProviderId,
getProviderModule,
createProvider,
getProviderLabel,
};
});
+112 -16
View File
@@ -68,9 +68,99 @@
}
function buildRecordId(record = {}) {
return String(record.recordId || record.email || '')
.trim()
.toLowerCase();
const rawRecordId = String(record.recordId || '').trim();
if (rawRecordId) {
return rawRecordId.toLowerCase();
}
const rawIdentifierType = String(record.accountIdentifierType || '').trim().toLowerCase();
const hasPhoneOnlyIdentifier = !record.email && (
record.phoneNumber
|| record.phone
|| record.number
|| (record.accountIdentifier && !/@/.test(String(record.accountIdentifier || '')))
);
const identifierType = rawIdentifierType === 'phone'
|| (!rawIdentifierType && hasPhoneOnlyIdentifier)
? 'phone'
: 'email';
const identifier = String(
record.accountIdentifier
|| (identifierType === 'phone' ? (record.phoneNumber || record.phone || record.number || '') : (record.email || ''))
|| ''
).trim();
if (!identifier) {
return '';
}
return identifierType === 'phone'
? `phone:${identifier.toLowerCase()}`
: identifier.toLowerCase();
}
function getRecordIdentifierType(record = {}) {
const rawType = String(record.accountIdentifierType || '').trim().toLowerCase();
if (rawType === 'phone') {
return 'phone';
}
if (rawType === 'email') {
return 'email';
}
if (!record.email && (record.phoneNumber || record.phone || record.number)) {
return 'phone';
}
if (!record.email && record.accountIdentifier && !/@/.test(String(record.accountIdentifier || ''))) {
return 'phone';
}
return 'email';
}
function getRecordEmail(record = {}) {
const identifierType = getRecordIdentifierType(record);
return String(
record.email
|| (identifierType === 'email' ? record.accountIdentifier : '')
|| ''
).trim();
}
function getRecordPhoneNumber(record = {}) {
const identifierType = getRecordIdentifierType(record);
return String(
record.phoneNumber
|| record.phone
|| record.number
|| (identifierType === 'phone' ? record.accountIdentifier : '')
|| ''
).trim();
}
function getRecordPrimaryIdentifier(record = {}) {
const identifierType = getRecordIdentifierType(record);
const email = getRecordEmail(record);
const phoneNumber = getRecordPhoneNumber(record);
return identifierType === 'phone'
? (phoneNumber || String(record.accountIdentifier || '').trim() || email)
: (email || String(record.accountIdentifier || '').trim() || phoneNumber);
}
function getRecordSecondaryIdentifier(record = {}) {
const identifierType = getRecordIdentifierType(record);
const email = getRecordEmail(record);
const phoneNumber = getRecordPhoneNumber(record);
if (identifierType === 'phone' && email) {
return `邮箱 ${email}`;
}
if (identifierType !== 'phone' && phoneNumber) {
return `绑定手机号 ${phoneNumber}`;
}
return '';
}
function getRecordTitle(record = {}) {
const primaryIdentifier = getRecordPrimaryIdentifier(record) || '(空账号)';
const secondaryIdentifier = getRecordSecondaryIdentifier(record);
return secondaryIdentifier
? `${primaryIdentifier} / ${secondaryIdentifier}`
: primaryIdentifier;
}
function getAccountRunRecords(currentState = state.getLatestState()) {
@@ -262,7 +352,7 @@
}
if (!allRecords.length) {
dom.accountRecordsMeta.textContent = '暂无邮箱记录';
dom.accountRecordsMeta.textContent = '暂无账号记录';
return;
}
@@ -337,7 +427,7 @@
const message = allRecords.length
? `当前筛选“${getFilterConfig(activeFilter).metaLabel}”下暂无记录`
: '暂无邮箱记录';
: '暂无账号记录';
dom.accountRecordsList.innerHTML = `<div class="account-records-empty">${escapeHtml(message)}</div>`;
}
@@ -357,6 +447,9 @@
dom.accountRecordsList.innerHTML = visibleRecords.map((record) => {
const recordId = buildRecordId(record);
const primaryIdentifier = getRecordPrimaryIdentifier(record) || '(空账号)';
const secondaryIdentifier = getRecordSecondaryIdentifier(record);
const recordTitle = getRecordTitle(record);
const statusMeta = getStatusMeta(record);
const summaryText = getRecordSummaryText(record);
const retryCount = normalizeRetryCount(record.retryCount);
@@ -383,12 +476,15 @@
<div
class="${itemClassNames}"
data-account-record-id="${escapeHtml(recordId)}"
title="${escapeHtml(String(record.email || ''))}"
title="${escapeHtml(recordTitle)}"
>
<div class="account-record-item-top">
<div class="account-record-item-email-row">
${selectionMarkup}
<div class="account-record-item-email mono">${escapeHtml(String(record.email || '').trim() || '(空邮箱)')}</div>
<div class="account-record-item-identity">
<div class="account-record-item-email mono">${escapeHtml(primaryIdentifier)}</div>
${secondaryIdentifier ? `<div class="account-record-item-secondary mono">${escapeHtml(secondaryIdentifier)}</div>` : ''}
</div>
</div>
<div class="account-record-item-side">
<span class="account-record-item-status">${escapeHtml(statusMeta.label)}</span>
@@ -470,13 +566,13 @@
async function clearRecords() {
const records = getAccountRunRecords();
if (!records.length) {
helpers.showToast?.('没有可清理的邮箱记录。', 'warn', 1800);
helpers.showToast?.('没有可清理的账号记录。', 'warn', 1800);
return;
}
const confirmed = await helpers.openConfirmModal({
title: '清理邮箱记录',
message: '确认清理当前全部邮箱记录吗?该操作会同时清空面板记录与本地同步快照。',
title: '清理账号记录',
message: '确认清理当前全部账号记录吗?该操作会同时清空面板记录与本地同步快照。',
confirmLabel: '确认清理',
confirmVariant: 'btn-danger',
});
@@ -497,19 +593,19 @@
selectionMode = false;
resetSelection();
state.syncLatestState({ accountRunHistory: [] });
helpers.showToast?.(`已清理 ${Math.max(0, Number(response?.clearedCount) || 0)}邮箱记录。`, 'success', 2200);
helpers.showToast?.(`已清理 ${Math.max(0, Number(response?.clearedCount) || 0)}账号记录。`, 'success', 2200);
}
async function deleteSelectedRecords() {
const recordIds = Array.from(selectedRecordIds).filter(Boolean);
if (!recordIds.length) {
helpers.showToast?.('请先勾选要删除的邮箱记录。', 'warn', 1800);
helpers.showToast?.('请先勾选要删除的账号记录。', 'warn', 1800);
return;
}
const confirmed = await helpers.openConfirmModal({
title: '删除选中记录',
message: `确认删除选中的 ${recordIds.length}邮箱记录吗?该操作会同步更新本地 helper 快照。`,
message: `确认删除选中的 ${recordIds.length}账号记录吗?该操作会同步更新本地 helper 快照。`,
confirmLabel: '确认删除',
confirmVariant: 'btn-danger',
});
@@ -534,7 +630,7 @@
resetSelection();
state.syncLatestState({ accountRunHistory: nextRecords });
helpers.showToast?.(`已删除 ${Math.max(0, Number(response?.deletedCount) || 0)}邮箱记录。`, 'success', 2200);
helpers.showToast?.(`已删除 ${Math.max(0, Number(response?.deletedCount) || 0)}账号记录。`, 'success', 2200);
}
function handleStatsClick(event) {
@@ -619,14 +715,14 @@
try {
await deleteSelectedRecords();
} catch (error) {
helpers.showToast?.(`删除邮箱记录失败:${error.message}`, 'error');
helpers.showToast?.(`删除账号记录失败:${error.message}`, 'error');
}
});
dom.btnClearAccountRecords?.addEventListener('click', async () => {
try {
await clearRecords();
} catch (error) {
helpers.showToast?.(`清理邮箱记录失败:${error.message}`, 'error');
helpers.showToast?.(`清理账号记录失败:${error.message}`, 'error');
}
});
}
+534
View File
@@ -0,0 +1,534 @@
(function attachSidepanelCustomEmailPoolManager(globalScope) {
function createCustomEmailPoolManager(context = {}) {
const {
dom,
helpers,
state,
actions,
constants = {},
} = context;
const copyIcon = constants.copyIcon || '';
let renderedEntries = [];
let selectedEntryIds = new Set();
let searchTerm = '';
let filterMode = 'all';
let refreshQueued = false;
let loading = false;
function normalizeEmail(value = '') {
return String(value || '').trim().toLowerCase();
}
function isValidEmail(value = '') {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizeEmail(value));
}
function createEntryId() {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `custom-pool-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function normalizeEntry(rawEntry = {}) {
const email = normalizeEmail(rawEntry?.email || '');
if (!isValidEmail(email)) {
return null;
}
return {
id: String(rawEntry?.id || createEntryId()),
email,
enabled: rawEntry?.enabled !== undefined ? Boolean(rawEntry.enabled) : true,
used: Boolean(rawEntry?.used),
note: String(rawEntry?.note || '').trim(),
lastUsedAt: Number.isFinite(Number(rawEntry?.lastUsedAt)) ? Number(rawEntry.lastUsedAt) : 0,
};
}
function normalizeEntries(entries = []) {
if (!Array.isArray(entries)) {
return [];
}
const seenEmails = new Set();
const normalized = [];
for (const entry of entries) {
const item = normalizeEntry(entry);
if (!item) continue;
if (seenEmails.has(item.email)) continue;
seenEmails.add(item.email);
normalized.push(item);
}
return normalized;
}
function withCurrentFlag(entries = renderedEntries) {
const currentEmail = normalizeEmail(state.getCurrentEmail?.());
return normalizeEntries(entries).map((entry) => ({
...entry,
current: Boolean(currentEmail) && entry.email === currentEmail,
}));
}
function normalizeSearchText(value = '') {
return String(value || '').trim().toLowerCase();
}
function getFilteredEntries(entries = renderedEntries) {
const normalizedSearchTerm = normalizeSearchText(searchTerm);
return withCurrentFlag(entries).filter((entry) => {
const matchesFilter = (() => {
switch (filterMode) {
case 'enabled': return Boolean(entry.enabled);
case 'disabled': return !entry.enabled;
case 'used': return Boolean(entry.used);
case 'unused': return !entry.used;
case 'current': return Boolean(entry.current);
default: return true;
}
})();
if (!matchesFilter) return false;
if (!normalizedSearchTerm) return true;
const haystack = [
entry.email,
entry.note,
entry.enabled ? 'enabled 启用' : 'disabled 停用',
entry.used ? 'used 已用' : 'unused 未用',
entry.current ? 'current 当前' : '',
].join(' ').toLowerCase();
return haystack.includes(normalizedSearchTerm);
});
}
function pruneSelection(entries = renderedEntries) {
const existingIds = new Set(withCurrentFlag(entries).map((entry) => String(entry.id)));
selectedEntryIds = new Set([...selectedEntryIds].filter((id) => existingIds.has(id)));
}
function updateBulkUi(visibleEntries = getFilteredEntries()) {
if (!dom.checkboxCustomEmailPoolSelectAll || !dom.customEmailPoolSelectionSummary) {
return;
}
const visibleIds = visibleEntries.map((entry) => String(entry.id));
const selectedVisibleCount = visibleIds.filter((id) => selectedEntryIds.has(id)).length;
const hasVisible = visibleIds.length > 0;
const hasSelection = selectedEntryIds.size > 0;
dom.checkboxCustomEmailPoolSelectAll.checked = hasVisible && selectedVisibleCount === visibleIds.length;
dom.checkboxCustomEmailPoolSelectAll.indeterminate = selectedVisibleCount > 0 && selectedVisibleCount < visibleIds.length;
dom.checkboxCustomEmailPoolSelectAll.disabled = loading || !hasVisible;
dom.customEmailPoolSelectionSummary.textContent = `已选 ${selectedEntryIds.size} 个(当前显示 ${visibleIds.length} 个)`;
if (dom.btnCustomEmailPoolBulkUsed) dom.btnCustomEmailPoolBulkUsed.disabled = loading || !hasSelection;
if (dom.btnCustomEmailPoolBulkUnused) dom.btnCustomEmailPoolBulkUnused.disabled = loading || !hasSelection;
if (dom.btnCustomEmailPoolBulkEnable) dom.btnCustomEmailPoolBulkEnable.disabled = loading || !hasSelection;
if (dom.btnCustomEmailPoolBulkDisable) dom.btnCustomEmailPoolBulkDisable.disabled = loading || !hasSelection;
if (dom.btnCustomEmailPoolBulkDelete) dom.btnCustomEmailPoolBulkDelete.disabled = loading || !hasSelection;
}
function setLoadingState(nextLoading, summary = '') {
loading = Boolean(nextLoading);
if (dom.btnCustomEmailPoolImport) dom.btnCustomEmailPoolImport.disabled = loading;
if (dom.btnCustomEmailPoolRefresh) dom.btnCustomEmailPoolRefresh.disabled = loading;
if (dom.btnCustomEmailPoolClearUsed) dom.btnCustomEmailPoolClearUsed.disabled = loading;
if (dom.btnCustomEmailPoolDeleteAll) dom.btnCustomEmailPoolDeleteAll.disabled = loading;
if (dom.inputCustomEmailPoolImport) dom.inputCustomEmailPoolImport.disabled = loading;
if (summary && dom.customEmailPoolSummary) {
dom.customEmailPoolSummary.textContent = summary;
}
updateBulkUi(getFilteredEntries());
}
function renderCustomEmailPoolEntries(entries = state.getEntries?.()) {
if (!dom.customEmailPoolList || !dom.customEmailPoolSummary) {
return;
}
renderedEntries = normalizeEntries(entries);
pruneSelection(renderedEntries);
dom.customEmailPoolList.innerHTML = '';
if (!renderedEntries.length) {
selectedEntryIds.clear();
dom.customEmailPoolList.innerHTML = '<div class="luckmail-empty">还没有自定义邮箱,先导入一批邮箱再开始。</div>';
dom.customEmailPoolSummary.textContent = '导入你提前准备好的注册邮箱,每行一个邮箱地址。';
if (dom.btnCustomEmailPoolClearUsed) dom.btnCustomEmailPoolClearUsed.disabled = true;
if (dom.btnCustomEmailPoolDeleteAll) dom.btnCustomEmailPoolDeleteAll.disabled = true;
updateBulkUi([]);
return;
}
const entriesWithCurrent = withCurrentFlag(renderedEntries);
const usedCount = entriesWithCurrent.filter((entry) => entry.used).length;
const enabledCount = entriesWithCurrent.filter((entry) => entry.enabled).length;
dom.customEmailPoolSummary.textContent = `已加载 ${entriesWithCurrent.length} 个邮箱,其中 ${enabledCount} 个启用,${usedCount} 个已标记为已用。`;
if (dom.btnCustomEmailPoolClearUsed) dom.btnCustomEmailPoolClearUsed.disabled = loading || usedCount === 0;
if (dom.btnCustomEmailPoolDeleteAll) dom.btnCustomEmailPoolDeleteAll.disabled = loading || entriesWithCurrent.length === 0;
const visibleEntries = getFilteredEntries(entriesWithCurrent);
if (!visibleEntries.length) {
dom.customEmailPoolList.innerHTML = '<div class="luckmail-empty">没有匹配当前筛选条件的邮箱。</div>';
updateBulkUi([]);
return;
}
for (const entry of visibleEntries) {
const entryId = String(entry.id);
const item = document.createElement('div');
item.className = `luckmail-item${entry.current ? ' is-current' : ''}`;
item.innerHTML = `
<input class="luckmail-item-check" type="checkbox" data-action="select" ${selectedEntryIds.has(entryId) ? 'checked' : ''} />
<div class="luckmail-item-main">
<div class="luckmail-item-email-row">
<div class="luckmail-item-email">${helpers.escapeHtml(entry.email || '(未知邮箱)')}</div>
<button
class="hotmail-copy-btn"
type="button"
data-action="copy-email"
title="复制邮箱"
aria-label="复制邮箱 ${helpers.escapeHtml(entry.email || '')}"
>${copyIcon}</button>
</div>
<div class="luckmail-item-meta">
${entry.current ? '<span class="luckmail-tag current">当前</span>' : ''}
${entry.used ? '<span class="luckmail-tag used">已用</span>' : '<span class="luckmail-tag active">未用</span>'}
${entry.enabled ? '<span class="luckmail-tag active">启用</span>' : '<span class="luckmail-tag disabled">停用</span>'}
${entry.note ? `<span class="luckmail-tag">${helpers.escapeHtml(entry.note)}</span>` : ''}
</div>
</div>
<div class="luckmail-item-actions">
<button class="btn btn-outline btn-xs" type="button" data-action="use">使用此邮箱</button>
<button class="btn btn-outline btn-xs" type="button" data-action="toggle-used">${helpers.escapeHtml(entry.used ? '标记未用' : '标记已用')}</button>
<button class="btn btn-outline btn-xs" type="button" data-action="toggle-enabled">${helpers.escapeHtml(entry.enabled ? '停用' : '启用')}</button>
<button class="btn btn-outline btn-xs" type="button" data-action="delete">删除</button>
</div>
`;
item.querySelector('[data-action="select"]').addEventListener('change', (event) => {
if (event.target.checked) {
selectedEntryIds.add(entryId);
} else {
selectedEntryIds.delete(entryId);
}
updateBulkUi(visibleEntries);
});
item.querySelector('[data-action="copy-email"]').addEventListener('click', async () => {
await helpers.copyTextToClipboard(entry.email || '');
helpers.showToast('邮箱已复制', 'success', 1600);
});
item.querySelector('[data-action="use"]').addEventListener('click', async () => {
try {
setLoadingState(true, '正在切换当前邮箱...');
await actions.setRuntimeEmail?.(entry.email);
helpers.showToast(`已切换到 ${entry.email}`, 'success', 1800);
queueCustomEmailPoolRefresh();
} catch (error) {
helpers.showToast(`切换邮箱失败:${error.message}`, 'error');
} finally {
setLoadingState(false);
}
});
item.querySelector('[data-action="toggle-used"]').addEventListener('click', async () => {
await patchEntries((entriesList) => entriesList.map((candidate) => (
String(candidate.id) === entryId
? {
...candidate,
used: !entry.used,
lastUsedAt: !entry.used ? Date.now() : candidate.lastUsedAt,
}
: candidate
)));
});
item.querySelector('[data-action="toggle-enabled"]').addEventListener('click', async () => {
await patchEntries((entriesList) => entriesList.map((candidate) => (
String(candidate.id) === entryId
? { ...candidate, enabled: !entry.enabled }
: candidate
)));
});
item.querySelector('[data-action="delete"]').addEventListener('click', async () => {
await deleteEntries({
ids: [entry.id],
}, `确认删除 ${entry.email} 吗?此操作不可撤销。`);
});
dom.customEmailPoolList.appendChild(item);
}
updateBulkUi(visibleEntries);
}
async function patchEntries(mutator) {
const previousEntries = normalizeEntries(state.getEntries?.() || []);
const nextEntries = normalizeEntries(mutator(previousEntries.map((entry) => ({ ...entry }))));
setLoadingState(true, '正在更新自定义邮箱池...');
state.setEntries?.(nextEntries);
renderCustomEmailPoolEntries(nextEntries);
try {
await actions.persistEntries?.();
} catch (error) {
state.setEntries?.(previousEntries);
renderCustomEmailPoolEntries(previousEntries);
helpers.showToast(`更新自定义邮箱池失败:${error.message}`, 'error');
} finally {
setLoadingState(false);
}
}
async function deleteEntries(payload = {}, confirmMessage = '') {
const confirmed = await helpers.openConfirmModal({
title: '删除邮箱',
message: confirmMessage || '确认删除选中的邮箱吗?此操作不可撤销。',
confirmLabel: '确认删除',
confirmVariant: 'btn-danger',
});
if (!confirmed) {
return;
}
const ids = Array.isArray(payload.ids)
? payload.ids.map((id) => String(id || '').trim()).filter(Boolean)
: [];
const mode = String(payload.mode || '').trim().toLowerCase();
await patchEntries((entriesList) => {
if (mode === 'all') {
selectedEntryIds.clear();
return [];
}
if (mode === 'used') {
const usedIds = new Set(entriesList.filter((entry) => entry.used).map((entry) => String(entry.id)));
usedIds.forEach((id) => selectedEntryIds.delete(id));
return entriesList.filter((entry) => !entry.used);
}
const targetIds = new Set(ids);
ids.forEach((id) => selectedEntryIds.delete(id));
return entriesList.filter((entry) => !targetIds.has(String(entry.id)));
});
}
async function importEntriesFromTextarea() {
const text = String(dom.inputCustomEmailPoolImport?.value || '');
if (!text.trim()) {
helpers.showToast('请先粘贴邮箱列表,每行一个邮箱。', 'warn');
return;
}
const previousEntries = normalizeEntries(state.getEntries?.() || []);
const knownEmails = new Set(previousEntries.map((entry) => entry.email));
const importedEntries = [];
let skippedCount = 0;
for (const line of String(text || '').split(/[\r\n,;]+/)) {
const email = normalizeEmail(line);
if (!email) {
continue;
}
if (!isValidEmail(email) || knownEmails.has(email)) {
skippedCount += 1;
continue;
}
knownEmails.add(email);
importedEntries.push({
id: createEntryId(),
email,
enabled: true,
used: false,
note: '',
lastUsedAt: 0,
});
}
if (!importedEntries.length && skippedCount > 0) {
helpers.showToast('没有可导入的新邮箱(可能都重复或无效)。', 'warn');
return;
}
const nextEntries = normalizeEntries([...previousEntries, ...importedEntries]);
setLoadingState(true, '正在导入邮箱...');
state.setEntries?.(nextEntries);
renderCustomEmailPoolEntries(nextEntries);
try {
await actions.persistEntries?.();
if (dom.inputCustomEmailPoolImport) {
dom.inputCustomEmailPoolImport.value = '';
}
helpers.showToast(
skippedCount > 0
? `已导入 ${importedEntries.length} 个邮箱,跳过 ${skippedCount} 条无效或重复数据。`
: `已导入 ${importedEntries.length} 个邮箱。`,
importedEntries.length > 0 ? 'success' : 'warn',
2400
);
} catch (error) {
state.setEntries?.(previousEntries);
renderCustomEmailPoolEntries(previousEntries);
helpers.showToast(`导入邮箱失败:${error.message}`, 'error');
} finally {
setLoadingState(false);
}
}
async function refreshCustomEmailPoolEntries(options = {}) {
const { silent = false } = options;
if (state.isVisible && !state.isVisible()) {
return;
}
if (!silent) {
setLoadingState(true, '正在刷新自定义邮箱池...');
}
renderCustomEmailPoolEntries(state.getEntries?.());
if (!silent) {
setLoadingState(false);
}
}
function queueCustomEmailPoolRefresh() {
if (refreshQueued) return;
refreshQueued = true;
setTimeout(() => {
refreshQueued = false;
refreshCustomEmailPoolEntries({ silent: true });
}, 120);
}
function reset() {
selectedEntryIds.clear();
searchTerm = '';
filterMode = 'all';
if (dom.inputCustomEmailPoolSearch) dom.inputCustomEmailPoolSearch.value = '';
if (dom.selectCustomEmailPoolFilter) dom.selectCustomEmailPoolFilter.value = 'all';
if (dom.customEmailPoolList) {
dom.customEmailPoolList.innerHTML = '';
}
if (dom.customEmailPoolSummary) {
dom.customEmailPoolSummary.textContent = '导入你提前准备好的注册邮箱,每行一个邮箱地址。';
}
updateBulkUi([]);
}
function bindEvents() {
dom.btnCustomEmailPoolRefresh?.addEventListener('click', async () => {
await refreshCustomEmailPoolEntries();
});
dom.btnCustomEmailPoolImport?.addEventListener('click', async () => {
await importEntriesFromTextarea();
});
dom.inputCustomEmailPoolImport?.addEventListener('keydown', async (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
await importEntriesFromTextarea();
}
});
dom.inputCustomEmailPoolSearch?.addEventListener('input', (event) => {
searchTerm = normalizeSearchText(event.target.value);
renderCustomEmailPoolEntries(renderedEntries);
});
dom.selectCustomEmailPoolFilter?.addEventListener('change', (event) => {
filterMode = String(event.target.value || 'all');
renderCustomEmailPoolEntries(renderedEntries);
});
dom.checkboxCustomEmailPoolSelectAll?.addEventListener('change', (event) => {
const visibleEntries = getFilteredEntries(renderedEntries);
const visibleIds = visibleEntries.map((entry) => String(entry.id));
if (event.target.checked) {
visibleIds.forEach((id) => selectedEntryIds.add(id));
} else {
visibleIds.forEach((id) => selectedEntryIds.delete(id));
}
renderCustomEmailPoolEntries(renderedEntries);
});
dom.btnCustomEmailPoolBulkUsed?.addEventListener('click', async () => {
const targetIds = new Set([...selectedEntryIds]);
await patchEntries((entriesList) => entriesList.map((entry) => (
targetIds.has(String(entry.id))
? { ...entry, used: true, lastUsedAt: entry.lastUsedAt || Date.now() }
: entry
)));
});
dom.btnCustomEmailPoolBulkUnused?.addEventListener('click', async () => {
const targetIds = new Set([...selectedEntryIds]);
await patchEntries((entriesList) => entriesList.map((entry) => (
targetIds.has(String(entry.id))
? { ...entry, used: false }
: entry
)));
});
dom.btnCustomEmailPoolBulkEnable?.addEventListener('click', async () => {
const targetIds = new Set([...selectedEntryIds]);
await patchEntries((entriesList) => entriesList.map((entry) => (
targetIds.has(String(entry.id))
? { ...entry, enabled: true }
: entry
)));
});
dom.btnCustomEmailPoolBulkDisable?.addEventListener('click', async () => {
const targetIds = new Set([...selectedEntryIds]);
await patchEntries((entriesList) => entriesList.map((entry) => (
targetIds.has(String(entry.id))
? { ...entry, enabled: false }
: entry
)));
});
dom.btnCustomEmailPoolBulkDelete?.addEventListener('click', async () => {
await deleteEntries({
ids: [...selectedEntryIds],
}, `确认删除当前选中的 ${selectedEntryIds.size} 个邮箱吗?此操作不可撤销。`);
});
dom.btnCustomEmailPoolClearUsed?.addEventListener('click', async () => {
await deleteEntries({
mode: 'used',
}, '确认删除当前所有已用邮箱吗?此操作不可撤销。');
});
dom.btnCustomEmailPoolDeleteAll?.addEventListener('click', async () => {
await deleteEntries({
mode: 'all',
}, '确认删除当前全部邮箱吗?此操作不可撤销。');
});
}
return {
bindEvents,
queueCustomEmailPoolRefresh,
refreshCustomEmailPoolEntries,
renderCustomEmailPoolEntries,
reset,
};
}
globalScope.SidepanelCustomEmailPoolManager = {
createCustomEmailPoolManager,
};
})(typeof window !== 'undefined' ? window : globalThis);
+228
View File
@@ -0,0 +1,228 @@
(function attachSidepanelEditableListPicker(globalScope) {
const editableListPickers = [];
function splitEditableListValues(value = '') {
return String(value || '')
.split(/[\r\n,,、]+/)
.map((name) => name.trim())
.filter(Boolean);
}
function normalizeEditableListValues(...sources) {
const values = [];
const seen = new Set();
const append = (value) => {
if (Array.isArray(value)) {
value.forEach(append);
return;
}
for (const item of splitEditableListValues(value)) {
const key = item.toLowerCase();
if (!key || seen.has(key)) {
continue;
}
seen.add(key);
values.push(item);
}
};
sources.forEach(append);
return values;
}
function createEditableListPicker(config = {}) {
const {
root,
input,
trigger,
current,
menu,
emptyLabel = '请先添加',
fallbackItems = [],
minItems = 0,
deleteLabel = '删除',
itemLabel = '项目',
normalizeItems = normalizeEditableListValues,
normalizeValue = (value) => String(value || '').trim(),
getItemValue = (item) => String(item || '').trim(),
getItemLabel = (item) => getItemValue(item),
getItemDeleteLabel = (item) => getItemLabel(item),
onDelete = null,
onDeleteError = null,
} = config;
const picker = {
root,
input,
trigger,
current,
menu,
items: [],
open: false,
};
const getFallbackItems = () => normalizeItems(fallbackItems);
const getNormalizedItemValue = (item) => normalizeValue(getItemValue(item));
const findItemByValue = (value) => {
const normalized = normalizeValue(value);
return picker.items.find((item) => getNormalizedItemValue(item) === normalized) || null;
};
const reportDeleteError = (error) => {
const fallbackMessage = `${deleteLabel}${itemLabel}失败。`;
if (typeof onDeleteError === 'function') {
onDeleteError(error, fallbackMessage);
return;
}
if (typeof globalScope.showToast === 'function') {
globalScope.showToast(error?.message || fallbackMessage, 'error');
}
};
picker.setOpen = (open) => {
picker.open = Boolean(open) && !trigger?.disabled;
if (menu) {
menu.hidden = !picker.open;
}
if (trigger) {
trigger.setAttribute('aria-expanded', picker.open ? 'true' : 'false');
trigger.classList?.toggle('is-open', picker.open);
}
};
picker.close = () => {
picker.setOpen(false);
};
picker.setVisible = (visible) => {
if (root) {
root.style.display = visible ? '' : 'none';
}
if (!visible) {
picker.close();
}
};
picker.setSelection = (value, options = {}) => {
const fallback = getNormalizedItemValue(picker.items[0]) || getNormalizedItemValue(getFallbackItems()[0]) || '';
const selected = normalizeValue(value) || fallback;
const selectedItem = findItemByValue(selected);
if (input) {
input.value = selected;
if (options.emit && typeof input.dispatchEvent === 'function') {
input.dispatchEvent(new Event('change', { bubbles: true }));
}
}
if (current) {
current.textContent = selectedItem ? getItemLabel(selectedItem) : (selected || emptyLabel);
}
};
picker.render = (items = [], selectedValue = '') => {
const normalizedItems = normalizeItems(items);
picker.items = normalizedItems.length ? normalizedItems : getFallbackItems();
const inputValue = normalizeValue(input?.value);
const selected = normalizeValue(selectedValue)
|| (findItemByValue(inputValue) ? inputValue : '')
|| getNormalizedItemValue(picker.items[0])
|| '';
if (trigger) {
trigger.disabled = picker.items.length === 0;
}
if (picker.items.length === 0) {
if (menu) {
menu.innerHTML = '';
}
picker.setSelection('', { emit: false });
picker.close();
return;
}
if (
!menu
|| typeof menu.appendChild !== 'function'
|| typeof globalScope.document === 'undefined'
|| typeof globalScope.document.createElement !== 'function'
) {
picker.setSelection(selected, { emit: false });
return;
}
menu.innerHTML = '';
picker.items.forEach((item) => {
const itemValue = getNormalizedItemValue(item);
const row = globalScope.document.createElement('div');
row.className = 'editable-list-option-row';
const option = globalScope.document.createElement('button');
option.type = 'button';
option.className = 'editable-list-option';
option.setAttribute('role', 'option');
option.setAttribute('aria-selected', itemValue === selected ? 'true' : 'false');
option.textContent = getItemLabel(item);
option.addEventListener('click', () => {
picker.setSelection(itemValue, { emit: true });
picker.close();
});
const deleteButton = globalScope.document.createElement('button');
deleteButton.type = 'button';
deleteButton.className = 'editable-list-delete';
deleteButton.textContent = deleteLabel;
deleteButton.title = `${deleteLabel}${itemLabel} ${getItemDeleteLabel(item)}`;
deleteButton.setAttribute('aria-label', `${deleteLabel}${itemLabel} ${getItemDeleteLabel(item)}`);
deleteButton.disabled = picker.items.length <= minItems;
deleteButton.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
if (typeof onDelete === 'function') {
Promise.resolve(onDelete(itemValue, item)).catch(reportDeleteError);
}
});
row.appendChild(option);
row.appendChild(deleteButton);
menu.appendChild(row);
});
picker.setSelection(selected, { emit: false });
};
trigger?.addEventListener('click', (event) => {
event.stopPropagation();
editableListPickers.forEach((item) => {
if (item !== picker) {
item.close();
}
});
picker.setOpen(!picker.open);
});
trigger?.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
picker.close();
}
});
menu?.addEventListener('click', (event) => {
event.stopPropagation();
});
editableListPickers.push(picker);
return picker;
}
function closeEditableListPickers() {
editableListPickers.forEach((picker) => picker.close());
}
function isClickInsideEditableListPicker(target) {
return editableListPickers.some((picker) => Boolean(picker.root?.contains(target)));
}
globalScope.SidepanelEditableListPicker = {
closeEditableListPickers,
createEditableListPicker,
isClickInsideEditableListPicker,
normalizeEditableListValues,
splitEditableListValues,
};
})(window);
+51 -1
View File
@@ -16,6 +16,8 @@
let actionInFlight = false;
let listExpanded = false;
let searchTerm = '';
let filterMode = 'all';
function getHotmailAccountsByUsage(mode = 'all', currentState = state.getLatestState()) {
const accounts = helpers.getHotmailAccounts(currentState);
@@ -171,6 +173,39 @@
return `status-${account.status || 'pending'}`;
}
function normalizeSearchText(value = '') {
return String(value || '').trim().toLowerCase();
}
function getFilteredHotmailAccounts(accounts, currentId = '') {
const normalizedSearchTerm = normalizeSearchText(searchTerm);
return accounts.filter((account) => {
const isCurrent = Boolean(currentId) && account.id === currentId;
const matchesFilter = (() => {
switch (filterMode) {
case 'current': return isCurrent;
case 'available': return !account.used;
case 'used': return Boolean(account.used);
case 'error': return account.status === 'error';
default: return true;
}
})();
if (!matchesFilter) return false;
if (!normalizedSearchTerm) return true;
const haystack = [
account.email,
account.status,
getHotmailAvailabilityLabel(account),
getHotmailStatusLabel(account),
isCurrent ? 'current 当前' : '',
].join(' ').toLowerCase();
return haystack.includes(normalizedSearchTerm);
});
}
function clearHotmailForm() {
dom.inputHotmailEmail.value = '';
dom.inputHotmailClientId.value = '';
@@ -209,7 +244,14 @@
return;
}
dom.hotmailAccountsList.innerHTML = accounts.map((account) => `
const visibleAccounts = getFilteredHotmailAccounts(accounts, currentId);
if (!visibleAccounts.length) {
dom.hotmailAccountsList.innerHTML = '<div class="hotmail-empty">没有匹配当前筛选条件的 Hotmail 账号。</div>';
updateHotmailListViewport();
return;
}
dom.hotmailAccountsList.innerHTML = visibleAccounts.map((account) => `
<div class="hotmail-account-item${account.id === currentId ? ' is-current' : ''}">
<div class="hotmail-account-top">
<div class="hotmail-account-title-row">
@@ -541,6 +583,14 @@
dom.btnAddHotmailAccount?.addEventListener('click', handleAddHotmailAccount);
dom.btnImportHotmailAccounts?.addEventListener('click', handleImportHotmailAccounts);
dom.inputHotmailSearch?.addEventListener('input', (event) => {
searchTerm = normalizeSearchText(event.target.value);
renderHotmailAccounts();
});
dom.selectHotmailFilter?.addEventListener('change', (event) => {
filterMode = String(event.target.value || 'all');
renderHotmailAccounts();
});
dom.hotmailAccountsList?.addEventListener('click', handleAccountListClick);
formController.sync();
}
+53 -12
View File
@@ -1164,9 +1164,13 @@ function formatIpProxyRuntimeStatus(state = latestState) {
}
if (reason === 'connectivity_failed') {
const prefix = endpointSummary ? `当前代理:${endpointSummary}${accountSourceTag}` : '当前代理:未知';
const targetUnreachable = /真实目标|chatgpt\.com 不可达|target:page_context/i.test(errorText || details);
const exitPart = exitIp ? `;当前出口:${exitSummary}` : '';
return {
stateClass: 'state-error',
text: `${prefix};连通性失败,请切换节点或重试。`,
text: targetUnreachable && exitIp
? `${prefix}${exitPart};ChatGPT 目标不可达,请切换支持 ChatGPT 的节点。`
: `${prefix}${exitPart};连通性失败,请切换节点或重试。`,
details: errorText || details,
hideCurrentDisplay: true,
};
@@ -1248,6 +1252,35 @@ function setIpProxyEnabledInlineStatus(state = {}, enabled = getSelectedIpProxyE
}
}
function updateIpProxyPromoOverflow() {
if (
typeof ipProxyPromo === 'undefined'
|| typeof ipProxyPromoText === 'undefined'
|| !ipProxyPromo
|| !ipProxyPromoText
) {
return;
}
if (!ipProxyPromo.getClientRects().length) {
ipProxyPromo.classList.remove('is-overflowing');
return;
}
const overflowing = ipProxyPromoText.scrollWidth > Math.max(0, ipProxyPromo.clientWidth - 2);
ipProxyPromo.classList.toggle('is-overflowing', overflowing);
}
function scheduleIpProxyPromoOverflowCheck() {
if (typeof globalThis.requestAnimationFrame === 'function') {
globalThis.requestAnimationFrame(updateIpProxyPromoOverflow);
return;
}
globalThis.setTimeout(updateIpProxyPromoOverflow, 0);
}
if (typeof globalThis.addEventListener === 'function') {
globalThis.addEventListener('resize', scheduleIpProxyPromoOverflowCheck);
}
function updateIpProxyUI(state = latestState) {
const enabled = getSelectedIpProxyEnabled();
const showSettings = enabled && ipProxySectionExpanded;
@@ -1279,6 +1312,9 @@ function updateIpProxyUI(state = latestState) {
if (rowIpProxyFold) {
rowIpProxyFold.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyPromo) {
rowIpProxyPromo.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyService) {
rowIpProxyService.style.display = showSettings ? '' : 'none';
}
@@ -1303,11 +1339,13 @@ function updateIpProxyUI(state = latestState) {
if (rowIpProxyPoolTargetCount) {
rowIpProxyPoolTargetCount.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyAutoSyncEnabled) {
rowIpProxyAutoSyncEnabled.style.display = showSettings ? '' : 'none';
const autoSyncEnabledRow = typeof rowIpProxyAutoSyncEnabled !== 'undefined' ? rowIpProxyAutoSyncEnabled : null;
const autoSyncIntervalRow = typeof rowIpProxyAutoSyncInterval !== 'undefined' ? rowIpProxyAutoSyncInterval : null;
if (autoSyncEnabledRow) {
autoSyncEnabledRow.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyAutoSyncInterval) {
rowIpProxyAutoSyncInterval.style.display = showSettings ? '' : 'none';
if (autoSyncIntervalRow) {
autoSyncIntervalRow.style.display = showSettings ? '' : 'none';
}
if (rowIpProxyHost) {
rowIpProxyHost.style.display = showSettings && isAccountMode ? '' : 'none';
@@ -1339,6 +1377,7 @@ function updateIpProxyUI(state = latestState) {
if (ipProxyLayout) {
ipProxyLayout.classList.toggle('is-account-only', !apiModeAvailable);
}
scheduleIpProxyPromoOverflowCheck();
if (selectIpProxyService) {
selectIpProxyService.value = service;
selectIpProxyService.disabled = true;
@@ -1402,15 +1441,17 @@ function updateIpProxyUI(state = latestState) {
if (inputIpProxyAccountList) {
inputIpProxyAccountList.disabled = !enabled || !isAccountMode || !accountListAvailable;
}
if (inputIpProxyAutoSyncEnabled) {
inputIpProxyAutoSyncEnabled.disabled = !enabled;
const autoSyncEnabledInput = typeof inputIpProxyAutoSyncEnabled !== 'undefined' ? inputIpProxyAutoSyncEnabled : null;
const autoSyncIntervalInput = typeof inputIpProxyAutoSyncIntervalMinutes !== 'undefined' ? inputIpProxyAutoSyncIntervalMinutes : null;
if (autoSyncEnabledInput) {
autoSyncEnabledInput.disabled = !enabled;
}
if (inputIpProxyAutoSyncIntervalMinutes) {
const autoSyncEnabled = Boolean(inputIpProxyAutoSyncEnabled?.checked);
if (!Number.isFinite(Number.parseInt(String(inputIpProxyAutoSyncIntervalMinutes.value || '').trim(), 10))) {
inputIpProxyAutoSyncIntervalMinutes.value = '15';
if (autoSyncIntervalInput) {
const autoSyncEnabled = Boolean(autoSyncEnabledInput?.checked);
if (!Number.isFinite(Number.parseInt(String(autoSyncIntervalInput.value || '').trim(), 10))) {
autoSyncIntervalInput.value = '15';
}
inputIpProxyAutoSyncIntervalMinutes.disabled = !enabled || !autoSyncEnabled;
autoSyncIntervalInput.disabled = !enabled || !autoSyncEnabled;
}
const runtimeStatus = formatIpProxyRuntimeStatus(runtimeState);
+59 -1
View File
@@ -17,6 +17,8 @@
let actionInFlight = false;
let listExpanded = false;
let editingAccountId = '';
let searchTerm = '';
let filterMode = 'all';
function getMail2925Accounts(currentState = state.getLatestState()) {
return helpers.getMail2925Accounts(currentState);
@@ -89,6 +91,47 @@
}
}
function normalizeSearchText(value = '') {
return String(value || '').trim().toLowerCase();
}
function getStatusKey(account) {
return typeof mail2925Utils.getMail2925AccountStatus === 'function'
? mail2925Utils.getMail2925AccountStatus(account, Date.now())
: 'ready';
}
function getFilteredMail2925Accounts(accounts, currentId = '') {
const normalizedSearchTerm = normalizeSearchText(searchTerm);
return accounts.filter((account) => {
const statusKey = getStatusKey(account);
const status = getStatusSnapshot(account);
const isCurrent = Boolean(currentId) && account.id === currentId;
const matchesFilter = (() => {
switch (filterMode) {
case 'current': return isCurrent;
case 'ready': return statusKey === 'ready';
case 'cooldown': return statusKey === 'cooldown';
case 'disabled': return statusKey === 'disabled';
case 'error': return statusKey === 'error';
default: return true;
}
})();
if (!matchesFilter) return false;
if (!normalizedSearchTerm) return true;
const haystack = [
account.email,
statusKey,
status.label,
isCurrent ? 'current 当前' : '',
].join(' ').toLowerCase();
return haystack.includes(normalizedSearchTerm);
});
}
function refreshManagedAliasBaseEmail() {
if (typeof helpers.refreshManagedAliasBaseEmail === 'function') {
helpers.refreshManagedAliasBaseEmail();
@@ -175,7 +218,14 @@
return;
}
dom.mail2925AccountsList.innerHTML = accounts.map((account) => {
const visibleAccounts = getFilteredMail2925Accounts(accounts, currentId);
if (!visibleAccounts.length) {
dom.mail2925AccountsList.innerHTML = '<div class="hotmail-empty">没有匹配当前筛选条件的 2925 账号。</div>';
updateMail2925ListViewport();
return;
}
dom.mail2925AccountsList.innerHTML = visibleAccounts.map((account) => {
const status = getStatusSnapshot(account);
const coolingDown = status.label === '冷却中';
return `
@@ -520,6 +570,14 @@
dom.btnAddMail2925Account?.addEventListener('click', handleAddMail2925Account);
dom.btnImportMail2925Accounts?.addEventListener('click', handleImportMail2925Accounts);
dom.inputMail2925Search?.addEventListener('input', (event) => {
searchTerm = normalizeSearchText(event.target.value);
renderMail2925Accounts();
});
dom.selectMail2925Filter?.addEventListener('change', (event) => {
filterMode = String(event.target.value || 'all');
renderMail2925Accounts();
});
dom.mail2925AccountsList?.addEventListener('click', handleAccountListClick);
syncEditUi();
formController.sync();
+118 -1
View File
@@ -9,6 +9,7 @@
} = context;
let actionInFlight = false;
let payPalAccountPicker = null;
function getPayPalAccounts(currentState = state.getLatestState()) {
return helpers.getPayPalAccounts(currentState);
@@ -27,6 +28,58 @@
)).join('');
}
function getPayPalAccountValue(account = {}) {
return String(account?.id || '').trim();
}
function getPayPalAccountLabel(account = {}) {
return String(account?.email || '(未命名账号)');
}
function normalizePickerPayPalAccounts(accounts = []) {
return Array.isArray(accounts)
? accounts.filter((account) => getPayPalAccountValue(account))
: [];
}
function getPayPalAccountPicker() {
if (payPalAccountPicker) {
return payPalAccountPicker;
}
const pickerModule = helpers.editableListPicker || globalScope.SidepanelEditableListPicker;
const createEditableListPicker = pickerModule?.createEditableListPicker;
if (
typeof createEditableListPicker !== 'function'
|| !dom.payPalAccountPickerRoot
|| !dom.btnPayPalAccountMenu
|| !dom.payPalAccountCurrent
|| !dom.payPalAccountMenu
) {
return null;
}
payPalAccountPicker = createEditableListPicker({
root: dom.payPalAccountPickerRoot,
input: dom.selectPayPalAccount,
trigger: dom.btnPayPalAccountMenu,
current: dom.payPalAccountCurrent,
menu: dom.payPalAccountMenu,
emptyLabel: '请先添加 PayPal 账号',
itemLabel: '账号',
normalizeItems: normalizePickerPayPalAccounts,
normalizeValue: (value) => String(value || '').trim(),
getItemValue: getPayPalAccountValue,
getItemLabel: getPayPalAccountLabel,
getItemDeleteLabel: getPayPalAccountLabel,
onDelete: (accountId) => handleDeletePayPalAccount(accountId),
onDeleteError: (error, fallbackMessage) => {
helpers.showToast(error?.message || fallbackMessage, 'error');
},
});
return payPalAccountPicker;
}
function applyPayPalAccountMutation(account) {
if (!account?.id) return;
const latestState = state.getLatestState();
@@ -43,10 +96,17 @@
const latestState = state.getLatestState();
const accounts = getPayPalAccounts(latestState);
const currentId = getCurrentPayPalAccountId(latestState);
const selectedId = accounts.some((account) => account.id === currentId) ? currentId : '';
const picker = getPayPalAccountPicker();
if (picker) {
picker.render(accounts, selectedId);
return;
}
dom.selectPayPalAccount.innerHTML = buildSelectOptions(accounts);
dom.selectPayPalAccount.disabled = accounts.length === 0;
dom.selectPayPalAccount.value = accounts.some((account) => account.id === currentId) ? currentId : '';
dom.selectPayPalAccount.value = selectedId;
}
async function syncSelectedPayPalAccount(options = {}) {
@@ -83,6 +143,62 @@
return response.account || null;
}
async function handleDeletePayPalAccount(accountId) {
if (actionInFlight) return;
const targetId = String(accountId || '').trim();
if (!targetId) {
return;
}
const latestState = state.getLatestState();
const accounts = getPayPalAccounts(latestState);
const targetAccount = accounts.find((account) => account.id === targetId);
if (!targetAccount) {
return;
}
actionInFlight = true;
if (dom.btnAddPayPalAccount) {
dom.btnAddPayPalAccount.disabled = true;
}
try {
const nextAccounts = accounts.filter((account) => account.id !== targetId);
const currentId = getCurrentPayPalAccountId(latestState);
const nextCurrentAccount = currentId === targetId
? (nextAccounts[0] || null)
: (nextAccounts.find((account) => account.id === currentId) || null);
const payload = {
paypalAccounts: nextAccounts,
currentPayPalAccountId: nextCurrentAccount?.id || '',
paypalEmail: String(nextCurrentAccount?.email || '').trim(),
paypalPassword: String(nextCurrentAccount?.password || ''),
};
const response = await runtime.sendMessage({
type: 'SAVE_SETTING',
source: 'sidepanel',
payload,
});
if (response?.error) {
throw new Error(response.error);
}
state.syncLatestState({
...payload,
currentPayPalAccountId: payload.currentPayPalAccountId || null,
});
renderPayPalAccounts();
helpers.showToast(`已删除 PayPal 账号:${targetAccount.email || targetId}`, 'success', 1600);
} finally {
actionInFlight = false;
if (dom.btnAddPayPalAccount) {
dom.btnAddPayPalAccount.disabled = false;
}
}
}
async function openPayPalAccountDialog() {
if (typeof helpers.openFormDialog !== 'function') {
throw new Error('表单弹窗能力未加载,请刷新扩展后重试。');
@@ -180,6 +296,7 @@
return {
bindPayPalEvents,
handleDeletePayPalAccount,
renderPayPalAccounts,
syncSelectedPayPalAccount,
};
+215 -17
View File
@@ -511,6 +511,13 @@ header {
gap: 4px;
}
.update-card-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.update-card-version {
font-size: 14px;
font-weight: 700;
@@ -834,6 +841,74 @@ header {
padding-top: 0;
}
.ip-proxy-promo-row {
min-height: 26px;
}
.ip-proxy-promo {
position: relative;
flex: 1;
min-width: 0;
height: 26px;
padding: 0 9px;
display: flex;
align-items: center;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--amber) 22%, var(--border));
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--amber-soft) 44%, var(--bg-base));
color: color-mix(in srgb, var(--amber) 82%, var(--text-secondary));
}
.ip-proxy-promo-track {
min-width: 100%;
display: inline-flex;
align-items: center;
white-space: nowrap;
font-size: 12px;
font-weight: 650;
line-height: 1;
letter-spacing: 0;
}
.ip-proxy-promo-item {
display: inline-block;
padding-right: 32px;
}
.ip-proxy-promo-copy {
display: none;
}
.ip-proxy-promo.is-overflowing .ip-proxy-promo-track {
min-width: auto;
animation: ip-proxy-promo-marquee 18s linear infinite;
will-change: transform;
}
.ip-proxy-promo.is-overflowing .ip-proxy-promo-copy {
display: inline-block;
}
.ip-proxy-promo:hover .ip-proxy-promo-track {
animation-play-state: paused;
}
@keyframes ip-proxy-promo-marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.ip-proxy-promo.is-overflowing .ip-proxy-promo-track {
animation: none;
}
.ip-proxy-promo.is-overflowing .ip-proxy-promo-copy {
display: none;
}
}
.phone-verification-card {
margin-top: 10px;
}
@@ -1572,6 +1647,27 @@ header {
background: var(--text-muted);
}
.account-pool-toolbar {
position: sticky;
top: 0;
z-index: 1;
display: flex;
gap: 8px;
align-items: center;
padding: 6px 4px 8px;
background: color-mix(in srgb, var(--bg-base) 92%, transparent);
backdrop-filter: blur(6px);
}
.account-pool-search {
flex: 1;
min-width: 0;
}
.account-pool-filter {
flex: 0 0 110px;
}
.hotmail-accounts-list {
display: flex;
flex-direction: column;
@@ -1875,6 +1971,102 @@ header {
.data-select:focus { border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-soft); }
[data-theme="dark"] .data-select { color-scheme: dark; }
.editable-list-picker {
position: relative;
flex: 1;
min-width: 0;
}
.editable-list-trigger {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-family: 'JetBrains Mono', monospace;
text-align: left;
}
.editable-list-caret {
width: 8px;
height: 8px;
border-right: 1.5px solid currentColor;
border-bottom: 1.5px solid currentColor;
transform: translateY(-2px) rotate(45deg);
flex: 0 0 auto;
opacity: 0.7;
}
.editable-list-trigger.is-open .editable-list-caret {
transform: translateY(2px) rotate(225deg);
}
.editable-list-menu {
position: absolute;
z-index: 70;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 220px;
overflow: auto;
padding: 4px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-base);
box-shadow: var(--shadow-md);
}
.editable-list-option-row {
display: flex;
align-items: center;
gap: 4px;
min-height: 30px;
}
.editable-list-option,
.editable-list-delete {
border: none;
border-radius: var(--radius-sm);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
transition: background var(--transition), color var(--transition);
}
.editable-list-option {
flex: 1;
min-width: 0;
padding: 6px 8px;
text-align: left;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.editable-list-option[aria-selected="true"] {
color: var(--blue);
background: var(--blue-soft);
}
.editable-list-option:hover,
.editable-list-delete:hover:not(:disabled) {
color: var(--text-primary);
background: var(--bg-hover);
}
.editable-list-delete {
flex: 0 0 auto;
padding: 5px 7px;
font-size: 11px;
}
.editable-list-delete:disabled {
cursor: not-allowed;
opacity: 0.42;
}
.data-check {
display: flex;
align-items: flex-start;
@@ -2175,6 +2367,11 @@ header {
min-height: 33px;
}
.signup-phone-runtime-inline,
.signup-phone-runtime-input {
width: 100%;
}
.hero-sms-price-preview-stack {
width: 100%;
display: flex;
@@ -2841,17 +3038,34 @@ header {
cursor: pointer;
}
.account-record-item-email {
.account-record-item-identity {
min-width: 0;
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
}
.account-record-item-email,
.account-record-item-secondary {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.account-record-item-email {
color: var(--text-primary);
font-size: 12px;
font-weight: 600;
}
.account-record-item-secondary {
color: var(--text-secondary);
font-size: 10px;
font-weight: 600;
}
.account-record-item-side {
display: flex;
align-items: center;
@@ -3072,22 +3286,6 @@ header {
white-space: pre-line;
}
.plus-contribution-prompt-copy {
display: block;
margin-bottom: 8px;
}
.plus-contribution-prompt-image {
display: block;
width: min(220px, 100%);
max-height: 280px;
object-fit: contain;
margin: 12px auto 0;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg-primary);
}
.modal-message a,
.modal-alert a {
color: var(--blue);
+251 -22
View File
@@ -98,8 +98,11 @@
<div id="update-card-version" class="update-card-version"></div>
<p id="update-card-summary" class="update-card-summary"></p>
</div>
<div class="update-card-actions">
<button id="btn-ignore-release" class="btn btn-outline btn-sm" type="button" hidden>忽略本次更新</button>
<button id="btn-open-release" class="btn btn-primary btn-sm" type="button">前往更新</button>
</div>
</div>
<p class="update-card-reminder">一定请先导出配置,再执行更新</p>
<div id="update-release-list" class="update-release-list"></div>
</div>
@@ -194,7 +197,18 @@
</div>
<div class="data-row" id="row-sub2api-group" style="display:none;">
<span class="data-label">分组</span>
<input type="text" id="input-sub2api-group" class="data-input" placeholder="默认 codex" />
<div class="data-inline">
<input type="hidden" id="input-sub2api-group" value="codex" />
<div id="sub2api-group-picker" class="editable-list-picker">
<button id="btn-sub2api-group-menu" class="data-select editable-list-trigger" type="button"
aria-haspopup="listbox" aria-expanded="false">
<span id="sub2api-group-current" class="truncate">codex</span>
<span class="editable-list-caret" aria-hidden="true"></span>
</button>
<div id="sub2api-group-menu" class="editable-list-menu" role="listbox" hidden></div>
</div>
<button id="btn-add-sub2api-group" class="btn btn-outline btn-sm data-inline-btn" type="button">添加</button>
</div>
</div>
<div class="data-row" id="row-sub2api-default-proxy" style="display:none;">
<span class="data-label">默认代理</span>
@@ -228,30 +242,128 @@
<span class="data-label">Plus 模式</span>
<div class="data-inline setting-pair">
<div class="setting-group setting-group-primary">
<label class="toggle-switch" for="input-plus-mode-enabled" title="开启后使用 Plus Checkout 订阅流程">
<label class="toggle-switch" for="input-plus-mode-enabled" title="开启后使用 Plus Checkout 支付授权流程">
<input type="checkbox" id="input-plus-mode-enabled" />
<span class="toggle-switch-track" aria-hidden="true">
<span class="toggle-switch-thumb"></span>
</span>
</label>
</div>
<div class="setting-group setting-group-secondary">
<select id="select-plus-payment-method" class="data-select plus-payment-method-select" style="display:none;" aria-label="Plus 支付方式">
<option value="paypal">PayPal 支付</option>
<option value="gopay">GoPay 支付</option>
</select>
<span class="setting-caption">Plus 订阅链路</span>
</div>
</div>
<div class="data-row" id="row-plus-payment-method" style="display:none;">
<span class="data-label">Plus 支付</span>
<div class="data-inline">
<select id="select-plus-payment-method" class="data-select">
<option value="paypal">PayPal</option>
<option value="gopay">GoPay</option>
<option value="gpc-helper">GPC</option>
</select>
<button id="btn-gpc-card-key-purchase" class="btn btn-outline btn-sm data-inline-btn" type="button" style="display:none;">购买卡密</button>
<span class="setting-caption" id="plus-payment-method-caption">PayPal 订阅链路</span>
</div>
</div>
<div class="data-row" id="row-paypal-account" style="display:none;">
<span class="data-label">PayPal 账号</span>
<div class="data-inline">
<select id="select-paypal-account" class="data-select">
<option value="">请先添加 PayPal 账号</option>
</select>
<input type="hidden" id="select-paypal-account" value="" />
<div id="paypal-account-picker" class="editable-list-picker">
<button id="btn-paypal-account-menu" class="data-select editable-list-trigger" type="button"
aria-haspopup="listbox" aria-expanded="false">
<span id="paypal-account-current" class="truncate">请先添加 PayPal 账号</span>
<span class="editable-list-caret" aria-hidden="true"></span>
</button>
<div id="paypal-account-menu" class="editable-list-menu" role="listbox" hidden></div>
</div>
<button id="btn-add-paypal-account" class="btn btn-outline btn-sm data-inline-btn" type="button">添加</button>
</div>
</div>
<div class="data-row" id="row-gpc-helper-card-key" style="display:none;">
<span class="data-label">GPC 卡密</span>
<div class="data-inline">
<div class="input-with-icon">
<input type="password" id="input-gpc-helper-card-key" class="data-input data-input-with-icon"
placeholder="请输入购买的 GPC 卡密" autocomplete="off" />
<button id="btn-toggle-gpc-helper-card-key" class="input-icon-btn" type="button"
data-password-toggle="input-gpc-helper-card-key" data-show-label="显示 GPC 卡密"
data-hide-label="隐藏 GPC 卡密" aria-label="显示 GPC 卡密" title="显示 GPC 卡密"></button>
</div>
<button id="btn-gpc-helper-balance" class="btn btn-ghost btn-xs data-inline-btn" type="button">查余额</button>
<span id="display-gpc-helper-balance" class="data-value mono">余额未获取</span>
</div>
</div>
<div class="data-row" id="row-gpc-helper-country-code" style="display:none;">
<span class="data-label">GPC 区号</span>
<select id="select-gpc-helper-country-code" class="data-select">
<option value="+86">中国 +86</option>
<option value="+62">印度尼西亚 +62</option>
<option value="+1">美国/加拿大 +1</option>
<option value="+852">香港 +852</option>
<option value="+853">澳门 +853</option>
<option value="+886">台湾 +886</option>
<option value="+60">马来西亚 +60</option>
<option value="+65">新加坡 +65</option>
<option value="+66">泰国 +66</option>
<option value="+84">越南 +84</option>
<option value="+63">菲律宾 +63</option>
<option value="+91">印度 +91</option>
</select>
</div>
<div class="data-row" id="row-gpc-helper-phone" style="display:none;">
<span class="data-label">GPC 手机</span>
<input type="text" id="input-gpc-helper-phone" class="data-input"
placeholder="GPC 专用手机号,不读取 GoPay 手机字段" autocomplete="tel" />
</div>
<div class="data-row" id="row-gpc-helper-pin" style="display:none;">
<span class="data-label">GPC PIN</span>
<div class="input-with-icon">
<input type="password" id="input-gpc-helper-pin" class="data-input data-input-with-icon"
placeholder="请输入 GoPay PIN" autocomplete="off" />
<button id="btn-toggle-gpc-helper-pin" class="input-icon-btn" type="button"
data-password-toggle="input-gpc-helper-pin" data-show-label="显示 GPC PIN"
data-hide-label="隐藏 GPC PIN" aria-label="显示 GPC PIN"
title="显示 GPC PIN"></button>
</div>
</div>
<div class="data-row" id="row-gopay-country-code" style="display:none;">
<span class="data-label">GoPay 区号</span>
<select id="select-gopay-country-code" class="data-select">
<option value="+86">中国 +86</option>
<option value="+62">印度尼西亚 +62</option>
<option value="+1">美国/加拿大 +1</option>
<option value="+852">香港 +852</option>
<option value="+853">澳门 +853</option>
<option value="+886">台湾 +886</option>
<option value="+60">马来西亚 +60</option>
<option value="+65">新加坡 +65</option>
<option value="+66">泰国 +66</option>
<option value="+84">越南 +84</option>
<option value="+63">菲律宾 +63</option>
<option value="+91">印度 +91</option>
</select>
</div>
<div class="data-row" id="row-gopay-phone" style="display:none;">
<span class="data-label">GoPay 手机</span>
<input type="text" id="input-gopay-phone" class="data-input"
placeholder="请输入不含区号的 GoPay / WhatsApp 手机号" autocomplete="tel" />
</div>
<div class="data-row" id="row-gopay-otp" style="display:none;">
<span class="data-label">GoPay 验证码</span>
<input type="text" id="input-gopay-otp" class="data-input"
placeholder="可选;留空时执行中会弹窗手动输入" inputmode="numeric" autocomplete="one-time-code" />
</div>
<div class="data-row" id="row-gopay-pin" style="display:none;">
<span class="data-label">GoPay PIN</span>
<div class="input-with-icon">
<input type="password" id="input-gopay-pin" class="data-input data-input-with-icon"
placeholder="请输入 GoPay PIN" autocomplete="off" />
<button id="btn-toggle-gopay-pin" class="input-icon-btn" type="button"
data-password-toggle="input-gopay-pin" data-show-label="显示 GoPay PIN"
data-hide-label="隐藏 GoPay PIN" aria-label="显示 GoPay PIN"
title="显示 GoPay PIN"></button>
</div>
</div>
<div class="data-row module-divider-start" id="row-mail-provider">
<span class="data-label">邮箱服务</span>
<div class="data-inline">
@@ -297,13 +409,67 @@
</div>
<div class="data-row data-check-row" id="row-custom-email-pool" style="display:none;">
<span class="data-label">邮箱池</span>
<textarea id="input-custom-email-pool" class="data-textarea"
<div class="hotmail-import-box">
<textarea id="input-custom-email-pool" class="data-textarea mono" hidden
placeholder="每行一个邮箱,例如&#10;alias001@gmail.com&#10;alias002@gmail.com"></textarea>
<div class="section-mini-header">
<div class="section-mini-copy">
<span class="section-label">自定义邮箱池</span>
</div>
<div class="section-mini-actions">
<button id="btn-custom-email-pool-refresh" class="btn btn-ghost btn-xs" type="button">刷新</button>
<button id="btn-custom-email-pool-clear-used" class="btn btn-ghost btn-xs" type="button">清空已用</button>
<button id="btn-custom-email-pool-delete-all" class="btn btn-ghost btn-xs" type="button">全部删除</button>
</div>
</div>
<textarea id="input-custom-email-pool-import" class="data-textarea mono" rows="4"
placeholder="每行一个邮箱,例如&#10;alias001@gmail.com&#10;alias002@gmail.com"></textarea>
<div class="data-inline">
<button id="btn-custom-email-pool-import" class="btn btn-primary btn-sm" type="button">导入邮箱</button>
</div>
<div class="icloud-card">
<div id="custom-email-pool-summary" class="icloud-summary">导入你提前准备好的注册邮箱,每行一个邮箱地址。</div>
<div class="icloud-toolbar">
<input id="input-custom-email-pool-search" class="data-input icloud-search" type="text"
placeholder="搜索邮箱 / 备注" />
<select id="select-custom-email-pool-filter" class="data-select icloud-filter">
<option value="all">全部</option>
<option value="current">当前</option>
<option value="enabled">启用</option>
<option value="disabled">停用</option>
<option value="used">已用</option>
<option value="unused">未用</option>
</select>
</div>
<div class="icloud-bulkbar">
<label class="option-toggle luckmail-select-all" for="checkbox-custom-email-pool-select-all">
<input type="checkbox" id="checkbox-custom-email-pool-select-all" />
<span id="custom-email-pool-selection-summary">已选 0 个</span>
</label>
<div class="luckmail-bulk-actions">
<button id="btn-custom-email-pool-bulk-used" class="btn btn-outline btn-xs" type="button">标记已用</button>
<button id="btn-custom-email-pool-bulk-unused" class="btn btn-outline btn-xs" type="button">标记未用</button>
<button id="btn-custom-email-pool-bulk-enable" class="btn btn-outline btn-xs" type="button">启用</button>
<button id="btn-custom-email-pool-bulk-disable" class="btn btn-outline btn-xs" type="button">停用</button>
<button id="btn-custom-email-pool-bulk-delete" class="btn btn-outline btn-xs" type="button">删除</button>
</div>
</div>
<div id="custom-email-pool-list" class="luckmail-list"></div>
</div>
</div>
</div>
<div class="data-row" id="row-cf-domain" style="display:none;">
<span class="data-label">CF 域名</span>
<div class="data-inline">
<select id="select-cf-domain" class="data-select"></select>
<input type="hidden" id="select-cf-domain" value="" />
<div id="cf-domain-picker" class="editable-list-picker">
<button id="btn-cf-domain-menu" class="data-select editable-list-trigger" type="button"
aria-haspopup="listbox" aria-expanded="false">
<span id="cf-domain-current" class="truncate">请先添加域名</span>
<span class="editable-list-caret" aria-hidden="true"></span>
</button>
<div id="cf-domain-menu" class="editable-list-menu" role="listbox" hidden></div>
</div>
<input type="text" id="input-cf-domain" class="data-input" placeholder="例如 yourdomain.xyz"
style="display:none;" />
<button id="btn-cf-domain-mode" class="btn btn-outline btn-sm data-inline-btn" type="button">添加</button>
@@ -477,7 +643,15 @@
<div class="data-row" id="row-temp-email-domain" style="display:none;">
<span class="data-label">Temp 域名</span>
<div class="data-inline">
<select id="select-temp-email-domain" class="data-select"></select>
<input type="hidden" id="select-temp-email-domain" value="" />
<div id="temp-email-domain-picker" class="editable-list-picker">
<button id="btn-temp-email-domain-menu" class="data-select editable-list-trigger" type="button"
aria-haspopup="listbox" aria-expanded="false">
<span id="temp-email-domain-current" class="truncate">请先添加域名</span>
<span class="editable-list-caret" aria-hidden="true"></span>
</button>
<div id="temp-email-domain-menu" class="editable-list-menu" role="listbox" hidden></div>
</div>
<input type="text" id="input-temp-email-domain" class="data-input" placeholder="例如 mail.example.com"
style="display:none;" />
<button id="btn-temp-email-domain-mode" class="btn btn-outline btn-sm" type="button">添加</button>
@@ -563,6 +737,17 @@
</div>
</div>
<div id="hotmail-list-shell" class="hotmail-list-shell is-collapsed">
<div class="account-pool-toolbar">
<input id="input-hotmail-search" class="data-input account-pool-search" type="text"
placeholder="搜索邮箱 / 状态" />
<select id="select-hotmail-filter" class="data-select account-pool-filter">
<option value="all">全部</option>
<option value="current">当前</option>
<option value="available">可分配</option>
<option value="used">已用</option>
<option value="error">异常</option>
</select>
</div>
<div id="hotmail-accounts-list" class="hotmail-accounts-list"></div>
</div>
</div>
@@ -611,6 +796,18 @@
</div>
</div>
<div id="mail2925-list-shell" class="hotmail-list-shell is-collapsed">
<div class="account-pool-toolbar">
<input id="input-mail2925-search" class="data-input account-pool-search" type="text"
placeholder="搜索邮箱 / 状态" />
<select id="select-mail2925-filter" class="data-select account-pool-filter">
<option value="all">全部</option>
<option value="current">当前</option>
<option value="ready">可用</option>
<option value="cooldown">冷却中</option>
<option value="disabled">已禁用</option>
<option value="error">异常</option>
</select>
</div>
<div id="mail2925-accounts-list" class="hotmail-accounts-list"></div>
</div>
</div>
@@ -798,6 +995,17 @@
<div class="data-row ip-proxy-fold-row" id="row-ip-proxy-fold" style="display:none;">
<div id="ip-proxy-fold" class="ip-proxy-fold">
<div class="ip-proxy-fold-body">
<div class="data-row ip-proxy-promo-row" id="row-ip-proxy-promo" style="display:none;">
<span class="data-label">渠道</span>
<div id="ip-proxy-promo" class="ip-proxy-promo"
title="711proxy动态等各大平台源头供应商,低价直冲6元1G,量大谈价.QQ:32597005">
<div id="ip-proxy-promo-track" class="ip-proxy-promo-track"
aria-label="711proxy动态等各大平台源头供应商,低价直冲6元1G,量大谈价.QQ:32597005">
<span id="ip-proxy-promo-text" class="ip-proxy-promo-item">711proxy动态等各大平台源头供应商,低价直冲6元1G,量大谈价.QQ:32597005</span>
<span class="ip-proxy-promo-item ip-proxy-promo-copy" aria-hidden="true">711proxy动态等各大平台源头供应商,低价直冲6元1G,量大谈价.QQ:32597005</span>
</div>
</div>
</div>
<div class="data-row" id="row-ip-proxy-service" style="display:none;">
<span class="data-label">代理服务</span>
<div class="data-inline">
@@ -978,6 +1186,13 @@
<div class="data-row phone-verification-fold-row" id="row-phone-verification-fold" style="display:none;">
<div id="phone-verification-fold" class="phone-verification-fold">
<div class="phone-verification-fold-body">
<div class="data-row" id="row-signup-method" style="display:none;">
<span class="data-label">注册方式</span>
<div id="signup-method-group" class="choice-group" role="group" aria-label="注册方式">
<button type="button" class="choice-btn" data-signup-method="email">邮箱注册</button>
<button type="button" class="choice-btn" data-signup-method="phone">手机号注册</button>
</div>
</div>
<div class="data-row" id="row-account-run-history-helper-base-url" style="display:none;">
<span class="data-label">同步服务</span>
<input type="text" id="input-account-run-history-helper-base-url" class="data-input mono"
@@ -986,9 +1201,9 @@
<div class="data-row" id="row-phone-sms-provider" style="display:none;">
<span class="data-label">接码服务商</span>
<select id="select-phone-sms-provider" class="data-select mono">
<option value="hero-sms">HeroSMS(原有)</option>
<option value="5sim">5sim(新增)</option>
<option value="nexsms">NexSMS(新增)</option>
<option value="hero-sms">HeroSMS</option>
<option value="5sim">5sim</option>
<option value="nexsms">NexSMS</option>
</select>
</div>
<div class="data-row" id="row-phone-sms-provider-order" style="display:none;">
@@ -1151,9 +1366,11 @@
<div class="data-inline hero-sms-price-preview-stack">
<div class="hero-sms-price-preview-head">
<button id="btn-hero-sms-price-preview" class="btn btn-outline btn-xs data-inline-btn" type="button">查询价格</button>
<button id="btn-phone-sms-balance" class="btn btn-ghost btn-xs data-inline-btn" type="button">查余额</button>
</div>
<div id="row-hero-sms-price-tiers" class="hero-sms-price-preview-result" style="display:none;">
<span id="display-hero-sms-price-tiers" class="data-value mono hero-sms-price-preview-text">未获取</span>
<span id="display-phone-sms-balance" class="data-value mono hero-sms-price-preview-text">余额未获取</span>
</div>
<div class="hero-sms-price-controls-grid">
<div class="hero-sms-price-control">
@@ -1230,6 +1447,9 @@
</div>
</div>
</div>
</div>
</div>
</div>
<div class="data-row" id="row-hero-sms-runtime-pair" style="display:none;">
<span class="data-label">运行状态</span>
<div class="data-inline hero-sms-runtime-grid">
@@ -1253,7 +1473,10 @@
</div>
</div>
</div>
</div>
<div class="data-row" id="row-signup-phone" style="display:none;">
<span class="data-label">注册手机号</span>
<div class="data-inline signup-phone-runtime-inline">
<input type="text" id="input-signup-phone" class="data-input mono signup-phone-runtime-input" placeholder="完整国际号码,如 +44..." />
</div>
</div>
</div>
@@ -1298,7 +1521,7 @@
<div class="log-header">
<span class="section-label">日志</span>
<div class="log-header-actions">
<button id="btn-open-account-records" class="btn btn-ghost btn-xs" title="查看邮箱记录">记录</button>
<button id="btn-open-account-records" class="btn btn-ghost btn-xs" title="查看账号记录">记录</button>
<button id="btn-clear-log" class="btn btn-ghost btn-xs" title="清空日志">清空</button>
</div>
</div>
@@ -1309,15 +1532,15 @@
<div class="account-records-panel">
<div class="account-records-panel-header">
<div class="account-records-panel-copy">
<span class="account-records-panel-title">邮箱记录</span>
<span id="account-records-meta" class="account-records-panel-meta">暂无邮箱记录</span>
<span class="account-records-panel-title">账号记录</span>
<span id="account-records-meta" class="account-records-panel-meta">暂无账号记录</span>
</div>
<div class="account-records-panel-actions">
<button id="btn-close-account-records" class="modal-close" type="button" aria-label="关闭">×</button>
</div>
</div>
<div class="account-records-toolbar">
<div id="account-records-stats" class="account-records-stats" role="group" aria-label="邮箱记录筛选"></div>
<div id="account-records-stats" class="account-records-stats" role="group" aria-label="账号记录筛选"></div>
<div class="account-records-toolbar-actions">
<button id="btn-toggle-account-records-selection" class="btn btn-ghost btn-xs" type="button">多选</button>
<button id="btn-delete-selected-account-records" class="btn btn-ghost btn-xs" type="button" hidden
@@ -1377,6 +1600,10 @@
<script src="../managed-alias-utils.js"></script>
<script src="../mail2925-utils.js"></script>
<script src="../paypal-utils.js"></script>
<script src="../gopay-utils.js"></script>
<script src="../phone-sms/providers/hero-sms.js"></script>
<script src="../phone-sms/providers/five-sim.js"></script>
<script src="../phone-sms/providers/registry.js"></script>
<script src="../icloud-utils.js"></script>
<script src="../mail-provider-utils.js"></script>
<script src="../hotmail-utils.js"></script>
@@ -1386,14 +1613,16 @@
<script src="contribution-content-update-service.js"></script>
<script src="account-pool-ui.js"></script>
<script src="form-dialog.js"></script>
<script src="editable-list-picker.js"></script>
<script src="hotmail-manager.js"></script>
<script src="mail-2925-manager.js"></script>
<script src="paypal-manager.js"></script>
<script src="icloud-manager.js"></script>
<script src="luckmail-manager.js"></script>
<script src="custom-email-pool-manager.js"></script>
<script src="contribution-mode.js"></script>
<script src="ip-proxy-provider-711proxy.js"></script>
<script src="ip-proxy-panel.js"></script>
<script src="contribution-mode.js"></script>
<script src="account-records-manager.js"></script>
<script src="sidepanel.js"></script>
</body>
+2825 -1233
View File
File diff suppressed because it is too large Load Diff
+52 -1
View File
@@ -4,6 +4,7 @@
const RELEASES_PAGE_URL = `https://github.com/${GITHUB_OWNER}/${GITHUB_REPO}/releases`;
const RELEASES_API_URL = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases?per_page=10`;
const CACHE_KEY = 'multipage-release-snapshot-v1';
const IGNORED_UPDATE_VERSION_KEY = 'multipage-ignored-release-version-v1';
const CACHE_TTL_MS = 60 * 60 * 1000;
const FETCH_TIMEOUT_MS = 8000;
const MAX_RELEASES = 10;
@@ -257,6 +258,32 @@
}
}
function getIgnoredUpdateVersion() {
try {
return String(localStorage.getItem(IGNORED_UPDATE_VERSION_KEY) || '').trim();
} catch (error) {
return '';
}
}
function setIgnoredUpdateVersion(version) {
const normalized = formatDisplayVersion(version, VERSION_FAMILY_ULTRA) || String(version || '').trim();
try {
if (normalized) {
localStorage.setItem(IGNORED_UPDATE_VERSION_KEY, normalized);
} else {
localStorage.removeItem(IGNORED_UPDATE_VERSION_KEY);
}
} catch (error) {
// Ignore localStorage failures.
}
return normalized;
}
function clearIgnoredUpdateVersion() {
return setIgnoredUpdateVersion('');
}
async function fetchReleases() {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
@@ -326,18 +353,39 @@
}
const newerReleases = releases.filter((release) => compareVersions(release.displayVersion, localVersion) > 0);
const ignoredVersion = getIgnoredUpdateVersion();
const newestUpdateVersion = newerReleases[0]?.displayVersion || '';
const updateIgnored = Boolean(newestUpdateVersion)
&& Boolean(ignoredVersion)
&& compareVersions(ignoredVersion, newestUpdateVersion) === 0;
return {
status: newerReleases.length > 0 ? 'update-available' : 'latest',
status: newerReleases.length > 0
? (updateIgnored ? 'ignored' : 'update-available')
: 'latest',
localVersion,
latestVersion: latestRelease.displayVersion,
latestRelease,
newerReleases,
ignoredVersion: updateIgnored ? ignoredVersion : '',
logUrl: latestRelease.url || RELEASES_PAGE_URL,
releasesPageUrl: RELEASES_PAGE_URL,
checkedAt: Date.now(),
};
}
function getSnapshotUpdateVersion(snapshot = {}) {
const newerReleases = Array.isArray(snapshot?.newerReleases) ? snapshot.newerReleases : [];
return newerReleases[0]?.displayVersion || snapshot?.latestVersion || '';
}
function ignoreReleaseSnapshot(snapshot = {}) {
const targetVersion = getSnapshotUpdateVersion(snapshot);
if (!targetVersion) {
return '';
}
return setIgnoredUpdateVersion(targetVersion);
}
function getLocalVersionLabel(manifest = chrome.runtime.getManifest()) {
const versionName = formatDisplayVersion(manifest?.version_name, VERSION_FAMILY_ULTRA);
if (versionName) {
@@ -389,8 +437,11 @@
compareVersions,
formatDisplayVersion,
formatReleaseDate,
clearIgnoredUpdateVersion,
getIgnoredUpdateVersion,
getLocalVersionLabel,
getReleaseSnapshot,
ignoreReleaseSnapshot,
releasesPageUrl: RELEASES_PAGE_URL,
stripVersionPrefix,
};
+12
View File
@@ -9,7 +9,10 @@ test('address sources normalize supported countries and return local seeds', ()
assert.equal(api.normalizeCountryCode('Deutschland'), 'DE');
assert.equal(api.normalizeCountryCode('澳大利亚'), 'AU');
assert.equal(api.normalizeCountryCode('印尼'), 'ID');
assert.equal(api.normalizeCountryCode('日本'), 'JP');
assert.equal(api.normalizeCountryCode('韩国'), 'KR');
assert.equal(api.normalizeCountryCode('South Korea'), 'KR');
assert.equal(api.normalizeCountryCode('unknown'), '');
const deSeed = api.getAddressSeedForCountry('DE');
@@ -22,7 +25,16 @@ test('address sources normalize supported countries and return local seeds', ()
assert.equal(fallbackSeed.countryCode, 'AU');
assert.equal(fallbackSeed.fallback.region, 'New South Wales');
const idSeed = api.getAddressSeedForCountry('Indonesia');
assert.equal(idSeed.countryCode, 'ID');
assert.equal(idSeed.fallback.region, 'DKI Jakarta');
const jpSeed = api.getAddressSeedForCountry('日本');
assert.equal(jpSeed.countryCode, 'JP');
assert.equal(jpSeed.fallback.region, 'Tokyo');
const krSeed = api.getAddressSeedForCountry('Korea');
assert.equal(krSeed.countryCode, 'KR');
assert.equal(krSeed.fallback.region, 'Seoul');
assert.match(krSeed.fallback.postalCode, /^\d{5}$/);
});
+334
View File
@@ -653,6 +653,172 @@ test('auto-run controller skips user_already_exists failures to the next round i
assert.equal(runtime.state.autoRunSessionId, 0);
});
test('auto-run controller skips step 4 repeated 405 recovery failures to the next round', async () => {
const events = {
logs: [],
broadcasts: [],
accountRecords: [],
runCalls: 0,
};
let currentState = {
stepStatuses: {},
vpsUrl: 'https://example.com/vps',
vpsPassword: 'secret',
customPassword: '',
autoRunSkipFailures: true,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
mailProvider: '163',
emailGenerator: 'duck',
gmailBaseEmail: '',
mail2925BaseEmail: '',
emailPrefix: 'demo',
inbucketHost: '',
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
tabRegistry: {},
sourceLastUrls: {},
autoRunRoundSummaries: [],
};
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = { ...this.state, ...updates };
},
};
let sessionSeed = 0;
const controller = api.createAutoRunController({
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
appendAccountRunRecord: async (status, _state, reason) => {
events.accountRecords.push({ status, reason });
return { status, reason };
},
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}) => {
events.broadcasts.push({ phase, ...payload });
currentState = {
...currentState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: () => {},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => error?.message || String(error || ''),
getFirstUnfinishedStep: () => 1,
getPendingAutoRunTimerPlan: () => null,
getRunningSteps: () => [],
getState: async () => ({
...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedProgress: () => false,
isAddPhoneAuthFailure: () => false,
isPlusCheckoutNonFreeTrialFailure: () => false,
isRestartCurrentAttemptError: () => false,
isSignupUserAlreadyExistsFailure: () => false,
isStep4Route405RecoveryLimitFailure: (error) => /STEP4_405_RECOVERY_LIMIT::/.test(error?.message || String(error || '')),
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
persistAutoRunTimerPlan: async () => ({}),
resetState: async () => {
currentState = {
...currentState,
stepStatuses: {},
tabRegistry: {},
sourceLastUrls: {},
};
},
runAutoSequenceFromStep: async () => {
events.runCalls += 1;
if (events.runCalls === 1) {
throw new Error('STEP4_405_RECOVERY_LIMIT::步骤 4:检测到 405 错误页面,已连续点击“重试”恢复 3/3 次仍未恢复,当前轮将结束并进入下一轮。');
}
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async () => {},
throwIfAutoRunSessionStopped: (sessionId) => {
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
throw new Error('流程已被用户停止。');
}
},
waitForRunningStepsToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage() {
return Promise.resolve();
},
},
},
});
await controller.autoRunLoop(2, {
autoRunSkipFailures: true,
mode: 'restart',
});
assert.equal(events.runCalls, 2, 'step 4 repeated 405 failure should skip the current round and continue with the next round');
assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'step 4 repeated 405 should not retry the same round');
assert.equal(events.accountRecords.length, 1, 'step 4 repeated 405 should persist a failed round record');
assert.equal(events.accountRecords[0].status, 'failed');
assert.match(events.accountRecords[0].reason, /STEP4_405_RECOVERY_LIMIT::/);
assert.ok(events.logs.some(({ message }) => /连续 405.*继续下一轮|继续下一轮/.test(message)));
assert.equal(runtime.state.autoRunActive, false);
assert.equal(runtime.state.autoRunSessionId, 0);
});
test('auto-run controller keeps retrying the same custom mail provider pool email until success', async () => {
const events = {
logs: [],
@@ -817,3 +983,171 @@ test('auto-run controller keeps retrying the same custom mail provider pool emai
assert.equal(runtime.state.autoRunActive, false);
assert.equal(runtime.state.autoRunSessionId, 0);
});
test('auto-run controller retries 5sim rate limit failures instead of treating current add-phone page as fatal', async () => {
const events = {
logs: [],
broadcasts: [],
accountRecords: [],
runCalls: 0,
sleeps: [],
};
let currentState = {
stepStatuses: {},
vpsUrl: 'https://example.com/vps',
vpsPassword: 'secret',
customPassword: '',
autoRunSkipFailures: true,
autoRunFallbackThreadIntervalMinutes: 0,
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
mailProvider: '163',
emailGenerator: 'duck',
gmailBaseEmail: '',
mail2925BaseEmail: '',
emailPrefix: 'demo',
inbucketHost: '',
inbucketMailbox: '',
cloudflareDomain: '',
cloudflareDomains: [],
tabRegistry: {},
sourceLastUrls: {},
autoRunRoundSummaries: [],
};
const runtime = {
state: {
autoRunActive: false,
autoRunCurrentRun: 0,
autoRunTotalRuns: 1,
autoRunAttemptRun: 0,
autoRunSessionId: 0,
},
get() {
return { ...this.state };
},
set(updates = {}) {
this.state = { ...this.state, ...updates };
},
};
let sessionSeed = 0;
const controller = api.createAutoRunController({
addLog: async (message, level = 'info') => {
events.logs.push({ message, level });
},
appendAccountRunRecord: async (status, _state, reason) => {
events.accountRecords.push({ status, reason });
return { status, reason };
},
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
AUTO_RUN_RETRY_DELAY_MS: 3000,
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
broadcastAutoRunStatus: async (phase, payload = {}) => {
events.broadcasts.push({ phase, ...payload });
currentState = {
...currentState,
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
};
},
broadcastStopToContentScripts: async () => {},
cancelPendingCommands: () => {},
clearStopRequest: () => {},
createAutoRunSessionId: () => {
sessionSeed += 1;
return sessionSeed;
},
getAutoRunStatusPayload: (phase, payload = {}) => ({
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
autoRunPhase: phase,
autoRunCurrentRun: payload.currentRun ?? 0,
autoRunTotalRuns: payload.totalRuns ?? 1,
autoRunAttemptRun: payload.attemptRun ?? 0,
autoRunSessionId: payload.sessionId ?? 0,
}),
getErrorMessage: (error) => error?.message || String(error || ''),
getFirstUnfinishedStep: () => 1,
getPendingAutoRunTimerPlan: () => null,
getRunningSteps: () => [],
getState: async () => ({
...currentState,
stepStatuses: { ...(currentState.stepStatuses || {}) },
tabRegistry: { ...(currentState.tabRegistry || {}) },
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
}),
getStopRequested: () => false,
hasSavedProgress: () => false,
isAddPhoneAuthFailure: (error) => /add-phone|手机号页面|手机号页|手机号码|手机号/i.test(error?.message || String(error || '')),
isPhoneSmsPlatformRateLimitFailure: (error) => /FIVE_SIM_RATE_LIMIT::|5sim[\s\S]*(?:限流|rate\s*limit)/i.test(error?.message || String(error || '')),
isRestartCurrentAttemptError: () => false,
isSignupUserAlreadyExistsFailure: () => false,
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
launchAutoRunTimerPlan: async () => false,
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
persistAutoRunTimerPlan: async () => ({}),
resetState: async () => {
currentState = {
...currentState,
stepStatuses: {},
tabRegistry: {},
sourceLastUrls: {},
};
},
runAutoSequenceFromStep: async () => {
events.runCalls += 1;
if (events.runCalls === 1) {
throw new Error('FIVE_SIM_RATE_LIMIT::5sim 购买接口触发限流,请稍后再试:印度 (India): rate limit。当前页面 https://auth.openai.com/add-phone');
}
},
runtime,
setState: async (updates = {}) => {
currentState = {
...currentState,
...updates,
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
};
},
sleepWithStop: async (ms) => {
events.sleeps.push(ms);
},
throwIfAutoRunSessionStopped: (sessionId) => {
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
throw new Error('流程已被用户停止。');
}
},
waitForRunningStepsToFinish: async () => currentState,
chrome: {
runtime: {
sendMessage() {
return Promise.resolve();
},
},
},
});
await controller.autoRunLoop(1, {
autoRunSkipFailures: true,
mode: 'restart',
});
assert.equal(events.runCalls, 2, '5sim rate limit should use same-round retry instead of add-phone fatal skip');
assert.equal(events.broadcasts.filter(({ phase }) => phase === 'retrying').length, 1);
assert.equal(events.accountRecords.length, 0);
assert.ok(events.logs.some(({ message }) => /自动重试/.test(message)));
assert.equal(events.logs.some(({ message }) => /触发 add-phone\/手机号页/.test(message)), false);
assert.equal(events.sleeps.filter((ms) => ms === 3000).length, 1);
assert.equal(runtime.state.autoRunActive, false);
assert.equal(runtime.state.autoRunSessionId, 0);
});
@@ -105,6 +105,15 @@ let currentState = {
autoRunDelayEnabled: false,
autoRunDelayMinutes: 30,
autoStepDelaySeconds: null,
signupMethod: 'phone',
resolvedSignupMethod: 'phone',
accountIdentifierType: 'phone',
accountIdentifier: '+6612345',
signupPhoneNumber: '+6612345',
signupPhoneActivation: { activationId: 'signup-activation', phoneNumber: '+6612345' },
signupPhoneCompletedActivation: { activationId: 'signup-completed', phoneNumber: '+6612345' },
signupPhoneVerificationRequestedAt: 123456,
signupPhoneVerificationPurpose: 'signup',
mailProvider: '163',
emailGenerator: 'duck',
gmailBaseEmail: 'demo@gmail.com',
@@ -162,6 +171,15 @@ async function resetState() {
autoRunDelayEnabled: prev.autoRunDelayEnabled,
autoRunDelayMinutes: prev.autoRunDelayMinutes,
autoStepDelaySeconds: prev.autoStepDelaySeconds,
signupMethod: prev.signupMethod,
resolvedSignupMethod: null,
accountIdentifierType: null,
accountIdentifier: '',
signupPhoneNumber: '',
signupPhoneActivation: null,
signupPhoneCompletedActivation: null,
signupPhoneVerificationRequestedAt: null,
signupPhoneVerificationPurpose: '',
mailProvider: prev.mailProvider,
emailGenerator: prev.emailGenerator,
gmailBaseEmail: prev.gmailBaseEmail,
@@ -335,6 +353,15 @@ return {
assert.strictEqual(snapshot.currentState.autoRunSessionId, 0, 'session id should be cleared after completion');
assert.strictEqual(snapshot.currentState.gmailBaseEmail, 'demo@gmail.com', 'gmail base email should survive fresh-attempt reset');
assert.strictEqual(snapshot.currentState.mail2925BaseEmail, 'demo@2925.com', '2925 base email should survive fresh-attempt reset');
assert.strictEqual(snapshot.currentState.signupMethod, 'phone', 'signup method setting should survive fresh-attempt reset');
assert.strictEqual(snapshot.currentState.resolvedSignupMethod, null, 'resolved signup method should be cleared before the next run freezes it again');
assert.strictEqual(snapshot.currentState.accountIdentifierType, null, 'account identifier type should be runtime-only');
assert.strictEqual(snapshot.currentState.accountIdentifier, '', 'account identifier should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneNumber, '', 'signup phone number should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneActivation, null, 'signup phone activation should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneCompletedActivation, null, 'completed signup phone activation should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneVerificationRequestedAt, null, 'signup phone request time should be runtime-only');
assert.strictEqual(snapshot.currentState.signupPhoneVerificationPurpose, '', 'signup phone purpose should be runtime-only');
assert.deepStrictEqual(
snapshot.currentState.reusablePhoneActivation,
{
@@ -66,6 +66,7 @@ test('auto-run stops step4 restart path when mail2925 ends the current thread',
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
const LAST_STEP_ID = 10;
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const SIGNUP_METHOD_PHONE = 'phone';
const chrome = {
tabs: {
update: async () => {},
@@ -107,6 +108,7 @@ async function ensureAutoEmailReady() {
}
async function broadcastAutoRunStatus() {}
async function ensureResolvedSignupMethodForRun() { return 'email'; }
async function getState() {
return currentState;
+6
View File
@@ -66,6 +66,7 @@ test('auto-run restarts from step 1 with the same email after step 4 failure', a
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
const LAST_STEP_ID = 10;
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const SIGNUP_METHOD_PHONE = 'phone';
const chrome = {
tabs: {
update: async () => {},
@@ -111,6 +112,7 @@ async function ensureAutoEmailReady() {
}
async function broadcastAutoRunStatus() {}
async function ensureResolvedSignupMethodForRun() { return 'email'; }
async function getState() {
return currentState;
@@ -215,6 +217,7 @@ test('auto-run does not restart step 4 current attempt when user_already_exists
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
const LAST_STEP_ID = 10;
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const SIGNUP_METHOD_PHONE = 'phone';
const chrome = {
tabs: {
update: async () => {},
@@ -256,6 +259,7 @@ async function ensureAutoEmailReady() {
}
async function broadcastAutoRunStatus() {}
async function ensureResolvedSignupMethodForRun() { return 'email'; }
async function getState() {
return currentState;
@@ -336,6 +340,7 @@ test('auto-run skips steps 4/5 when step 2 has already marked registration chain
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
const LAST_STEP_ID = 10;
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const SIGNUP_METHOD_PHONE = 'phone';
const chrome = {
tabs: {
update: async () => {},
@@ -376,6 +381,7 @@ async function ensureAutoEmailReady() {
}
async function broadcastAutoRunStatus() {}
async function ensureResolvedSignupMethodForRun() { return 'email'; }
async function getState() {
return currentState;
+24
View File
@@ -74,6 +74,7 @@ function createHarness(options = {}) {
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
const LAST_STEP_ID = 10;
const FINAL_OAUTH_CHAIN_START_STEP = 7;
const SIGNUP_METHOD_PHONE = 'phone';
const LOG_PREFIX = '[test]';
const chrome = {
tabs: {
@@ -93,6 +94,7 @@ async function addLog(message, level = 'info') {
}
async function ensureAutoEmailReady() {}
async function ensureResolvedSignupMethodForRun() { return 'email'; }
async function broadcastAutoRunStatus() {}
async function getState() {
return {
@@ -147,6 +149,10 @@ function getLoginAuthStateLabel(state) {
function getErrorMessage(error) {
return error?.message || String(error || '');
}
function isPhoneSmsPlatformRateLimitFailure(error) {
const message = getErrorMessage(error);
return /FIVE_SIM_RATE_LIMIT::|5sim[\s\S]*(?:限流|rate\s*limit)/i.test(message);
}
async function getLoginAuthStateFromContent() {
return ${JSON.stringify(authState)};
}
@@ -254,6 +260,24 @@ test('auto-run does not restart step 7 when phone verification exhausted replace
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
});
test('auto-run post-login restart decision does not treat 5sim rate limit on add-phone page as add-phone fatal', async () => {
const harness = createHarness({
failureStep: 9,
failureBudget: 1,
failureMessage: 'FIVE_SIM_RATE_LIMIT::5sim 购买接口触发限流,请稍后再试:印度 (India): rate limit。',
authState: { state: 'add_phone_page', url: 'https://auth.openai.com/add-phone' },
});
const result = await harness.runAndCaptureError();
assert.ok(result?.error);
assert.equal(result.events.invalidations.length, 0);
assert.deepStrictEqual(result.events.steps, [7, 8, 9]);
assert.ok(!result.events.logs.some(({ message }) => /进入 add-phone/.test(message)));
assert.ok(!result.events.logs.some(({ message }) => /回到步骤 7 重新开始授权流程/.test(message)));
});
test('auto-run stop errors after step 7 are rethrown immediately instead of restarting', async () => {
const harness = createHarness({
failureStep: 9,
@@ -54,12 +54,16 @@ test('background account history settings are normalized independently from hotm
extractFunction('normalizeHotmailLocalBaseUrl'),
extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
extractFunction('normalizeVerificationResendCount'),
extractFunction('normalizePlusPaymentMethod'),
extractFunction('normalizePhoneSmsProvider'),
extractFunction('normalizePhoneSmsProviderOrder'),
extractFunction('normalizeSignupMethod'),
extractFunction('normalizeFiveSimCountryCode'),
extractFunction('normalizeFiveSimCountryOrder'),
extractFunction('normalizeNexSmsCountryId'),
extractFunction('normalizeNexSmsCountryOrder'),
extractFunction('normalizeNexSmsServiceCode'),
extractFunction('normalizePhonePreferredActivation'),
extractFunction('normalizePhoneVerificationReplacementLimit'),
extractFunction('normalizePhoneCodeWaitSeconds'),
extractFunction('normalizePhoneCodeTimeoutWindows'),
@@ -67,6 +71,13 @@ test('background account history settings are normalized independently from hotm
extractFunction('normalizePhoneCodePollMaxRounds'),
extractFunction('normalizeHeroSmsMaxPrice'),
extractFunction('normalizeHeroSmsCountryFallback'),
extractFunction('normalizePhoneSmsProvider'),
extractFunction('normalizeFiveSimCountryId'),
extractFunction('normalizeFiveSimCountryLabel'),
extractFunction('normalizeFiveSimOperator'),
extractFunction('normalizeFiveSimMaxPrice'),
extractFunction('normalizeFiveSimCountryFallback'),
extractFunction('normalizeSub2ApiGroupNames'),
extractFunction('normalizePersistentSettingValue'),
].join('\n');
@@ -98,8 +109,52 @@ const VERIFICATION_RESEND_COUNT_MIN = 0;
const VERIFICATION_RESEND_COUNT_MAX = 20;
const HERO_SMS_COUNTRY_ID = 52;
const HERO_SMS_COUNTRY_LABEL = 'Thailand';
const PHONE_SMS_PROVIDER_HERO_SMS = 'hero-sms';
const PHONE_SMS_PROVIDER_FIVE_SIM = '5sim';
const PHONE_SMS_PROVIDER_NEXSMS = 'nexsms';
const DEFAULT_PHONE_SMS_PROVIDER_ORDER = ['hero-sms', '5sim', 'nexsms'];
const DEFAULT_PHONE_SMS_PROVIDER = PHONE_SMS_PROVIDER_HERO_SMS;
const SIGNUP_METHOD_EMAIL = 'email';
const SIGNUP_METHOD_PHONE = 'phone';
const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL;
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
const DEFAULT_FIVE_SIM_PRODUCT = 'openai';
const DEFAULT_NEX_SMS_SERVICE_CODE = 'ot';
const FIVE_SIM_COUNTRY_ID = 'vietnam';
const FIVE_SIM_COUNTRY_LABEL = '越南 (Vietnam)';
const FIVE_SIM_OPERATOR = 'any';
const FIVE_SIM_SUPPORTED_COUNTRY_ID_SET = new Set(['indonesia', 'thailand', 'vietnam']);
const HERO_SMS_SUPPORTED_COUNTRY_ID_SET = new Set(['6', '52', '10']);
const self = {
GoPayUtils: {
normalizeGoPayCountryCode(value) {
const digits = String(value || '').replace(/\\D/g, '');
return digits ? \`+\${digits}\` : '+86';
},
normalizeGoPayPhone(value) {
return String(value || '').trim().replace(/[^\\d+]/g, '');
},
normalizeGoPayOtp(value) {
return String(value || '').trim().replace(/[^\\d]/g, '');
},
normalizeGoPayPin(value) {
return String(value || '').trim().replace(/[^\\d]/g, '');
},
normalizeGpcHelperBaseUrl(value) {
return String(value || '')
.trim()
.replace(/\\/+$/g, '')
.replace(/\\/api\\/checkout\\/start$/i, '')
.replace(/\\/api\\/gopay\\/(?:otp|pin)$/i, '')
.replace(/\\/api\\/card\\/balance(?:\\?.*)?$/i, '');
},
},
};
const PERSISTED_SETTING_DEFAULTS = {
autoStepDelaySeconds: null,
gopayHelperApiUrl: 'https://gopay.hwork.pro',
mailProvider: '163',
};
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : (value === 'codex2api' ? 'codex2api' : 'cpa'); }
@@ -130,8 +185,18 @@ return {
assert.equal(api.normalizePersistentSettingValue('accountRunHistoryTextEnabled', 1), true);
assert.equal(api.normalizePersistentSettingValue('phoneVerificationEnabled', 1), true);
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'gopay'), 'gopay');
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'gpc-helper'), 'gpc-helper');
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'paypal'), 'paypal');
assert.equal(api.normalizePersistentSettingValue('plusPaymentMethod', 'unknown'), 'paypal');
assert.equal(
api.normalizePersistentSettingValue('gopayHelperApiUrl', ' https://gopay.hwork.pro/api/checkout/start '),
'https://gopay.hwork.pro'
);
assert.equal(api.normalizePersistentSettingValue('gopayHelperApiUrl', ''), 'https://gopay.hwork.pro');
assert.equal(api.normalizePersistentSettingValue('gopayHelperCardKey', ' card_123 '), 'card_123');
assert.equal(api.normalizePersistentSettingValue('gopayHelperCountryCode', ' 86 '), '+86');
assert.equal(api.normalizePersistentSettingValue('gopayHelperPhoneNumber', ' +86 138-0013-8000 '), '+8613800138000');
assert.equal(api.normalizePersistentSettingValue('gopayHelperPin', ' 12-34-56 '), '123456');
assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '7'), 7);
assert.equal(api.normalizePersistentSettingValue('verificationResendCount', '-1'), 0);
assert.equal(api.normalizePersistentSettingValue('phoneVerificationReplacementLimit', '9'), 9);
@@ -142,7 +207,25 @@ return {
assert.equal(api.normalizePersistentSettingValue('phoneCodePollMaxRounds', '18'), 18);
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0.123456'), '0.1235');
assert.equal(api.normalizePersistentSettingValue('heroSmsMaxPrice', '0'), '');
assert.equal(api.normalizePersistentSettingValue('heroSmsCountryId', 0), 0);
assert.equal(api.normalizePersistentSettingValue('heroSmsPreferredPrice', '0.051234'), '0.0512');
assert.equal(api.normalizePersistentSettingValue('signupMethod', 'phone'), 'phone');
assert.equal(api.normalizePersistentSettingValue('signupMethod', 'unknown'), 'email');
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', '5SIM'), '5sim');
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', 'NEXSMS'), 'nexsms');
assert.equal(api.normalizePersistentSettingValue('phoneSmsProvider', 'unknown'), 'hero-sms');
assert.deepStrictEqual(api.normalizePersistentSettingValue('phoneSmsProviderOrder', ['nexsms', '5sim', 'nexsms']), ['nexsms', '5sim']);
assert.equal(api.normalizePersistentSettingValue('fiveSimApiKey', ' demo-five '), ' demo-five ');
assert.equal(api.normalizePersistentSettingValue('fiveSimProduct', ' OpenAI! '), 'openai');
assert.equal(api.normalizePersistentSettingValue('fiveSimCountryId', ' England! '), 'england');
assert.equal(api.normalizePersistentSettingValue('fiveSimCountryId', ''), 'vietnam');
assert.equal(api.normalizePersistentSettingValue('fiveSimCountryLabel', ''), '越南 (Vietnam)');
assert.equal(api.normalizePersistentSettingValue('fiveSimMaxPrice', '9.87654'), '9.8765');
assert.equal(api.normalizePersistentSettingValue('fiveSimMaxPrice', '-1'), '');
assert.equal(api.normalizePersistentSettingValue('fiveSimOperator', ''), 'any');
assert.deepStrictEqual(
api.normalizePersistentSettingValue('fiveSimCountryFallback', [{ id: 'usa', label: 'USA' }, 'thailand:Thailand']),
[{ id: 'usa', label: 'USA' }, { id: 'thailand', label: 'Thailand' }]
);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('heroSmsCountryFallback', [{ id: 16, label: 'United Kingdom' }, { id: 52 }]),
[{ id: 16, label: 'United Kingdom' }, { id: 52, label: 'Country #52' }]
@@ -167,6 +250,10 @@ return {
api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ' proxy-a '),
'proxy-a'
);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('sub2apiGroupNames', [' codex ', 'openai-plus', 'CODEX']),
['codex', 'openai-plus']
);
assert.equal(
api.normalizePersistentSettingValue('codex2apiUrl', 'localhost:8080/admin'),
'http://localhost:8080/admin/accounts'
@@ -187,8 +274,36 @@ return {
api.normalizePersistentSettingValue('fiveSimCountryOrder', []),
[]
);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('fiveSimCountryOrder', [' Thailand ', 'vietnam', 'thailand']),
['thailand', 'vietnam']
);
assert.equal(api.normalizePersistentSettingValue('nexSmsApiKey', ' demo-nex '), ' demo-nex ');
assert.deepStrictEqual(
api.normalizePersistentSettingValue('nexSmsCountryOrder', []),
[]
);
assert.deepStrictEqual(
api.normalizePersistentSettingValue('nexSmsCountryOrder', [1, '6', 1]),
[1, 6]
);
assert.equal(api.normalizePersistentSettingValue('nexSmsServiceCode', ' OT! '), 'ot');
assert.deepStrictEqual(
api.normalizePersistentSettingValue('phonePreferredActivation', {
provider: 'nexsms',
activationId: 'abc',
phoneNumber: '+6612345',
successfulUses: 2,
maxUses: 3,
}),
{
provider: 'nexsms',
activationId: 'abc',
phoneNumber: '+6612345',
successfulUses: 2,
maxUses: 3,
countryId: null,
countryLabel: '',
}
);
});
@@ -73,7 +73,10 @@ test('account run history helper upgrades old records, keeps stopped items and s
);
assert.deepStrictEqual(record, {
recordId: 'latest@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'latest@example.com',
email: 'latest@example.com',
phoneNumber: '',
password: 'secret',
finalStatus: 'failed',
finishedAt: record.finishedAt,
@@ -140,6 +143,187 @@ test('account run history helper upgrades old records, keeps stopped items and s
assert.equal(normalizedStoppedRecord.failedStep, 7);
});
test('account run history helper accepts phone-only records without forcing email or password', () => {
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
const helpers = api.createAccountRunHistoryHelpers({
chrome: { storage: { local: { get: async () => ({}), set: async () => {} } } },
getState: async () => ({}),
normalizeAccountRunHistoryHelperBaseUrl: (value) => String(value || '').trim(),
});
const record = helpers.buildAccountRunHistoryRecord({
accountIdentifierType: 'phone',
accountIdentifier: '+6612345',
signupPhoneNumber: '+6612345',
password: '',
}, 'success');
assert.deepStrictEqual(record, {
recordId: 'phone:+6612345',
accountIdentifierType: 'phone',
accountIdentifier: '+6612345',
email: '',
phoneNumber: '+6612345',
password: '',
finalStatus: 'success',
finishedAt: record.finishedAt,
retryCount: 0,
failureLabel: '流程完成',
failureDetail: '',
failedStep: null,
source: 'manual',
autoRunContext: null,
plusModeEnabled: false,
contributionMode: false,
});
const normalized = helpers.normalizeAccountRunHistoryRecord({
recordId: 'phone:+6612345',
accountIdentifierType: 'phone',
accountIdentifier: '+6612345',
phoneNumber: '+6612345',
finalStatus: 'failed',
failureDetail: '步骤 8:手机号验证码超时。',
});
assert.equal(normalized.recordId, 'phone:+6612345');
assert.equal(normalized.accountIdentifierType, 'phone');
assert.equal(normalized.accountIdentifier, '+6612345');
assert.equal(normalized.email, '');
assert.equal(normalized.phoneNumber, '+6612345');
assert.equal(normalized.password, '');
assert.equal(normalized.finalStatus, 'failed');
});
test('account run history merges email and phone identities from the same run', async () => {
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
let storedHistory = [
{
recordId: 'phone:+447799342687',
accountIdentifierType: 'phone',
accountIdentifier: '+447799342687',
phoneNumber: '+44 7799 342687',
email: '',
password: '',
finalStatus: 'stopped',
finishedAt: '2026-04-17T04:30:00.000Z',
failureDetail: '步骤 2 已使用手机号,流程尚未完成。',
},
{
recordId: 'tmp@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'tmp@example.com',
email: 'tmp@example.com',
phoneNumber: '',
password: 'old',
finalStatus: 'stopped',
finishedAt: '2026-04-17T04:31:00.000Z',
failureDetail: '步骤 2 已使用邮箱,流程尚未完成。',
},
];
const helpers = api.createAccountRunHistoryHelpers({
chrome: {
storage: {
local: {
get: async () => ({ accountRunHistory: storedHistory }),
set: async (payload) => {
storedHistory = payload.accountRunHistory;
},
},
},
},
getState: async () => ({}),
normalizeAccountRunHistoryHelperBaseUrl: () => '',
});
const failedRecord = helpers.buildAccountRunHistoryRecord({
accountIdentifierType: 'email',
accountIdentifier: 'tmp@example.com',
email: 'tmp@example.com',
password: 'secret',
currentPhoneActivation: {
activationId: 'a1',
phoneNumber: '+44 7799 342687',
},
}, 'step9_failed', '步骤 9:手机号验证失败。');
assert.equal(failedRecord.accountIdentifierType, 'email');
assert.equal(failedRecord.accountIdentifier, 'tmp@example.com');
assert.equal(failedRecord.phoneNumber, '+44 7799 342687');
const successRecord = await helpers.appendAccountRunRecord('success', {
accountIdentifierType: 'email',
accountIdentifier: 'tmp@example.com',
email: 'tmp@example.com',
phoneNumber: '447799342687',
password: 'secret',
accountRunHistoryHelperBaseUrl: '',
});
assert.equal(successRecord.recordId, 'tmp@example.com');
assert.equal(successRecord.email, 'tmp@example.com');
assert.equal(successRecord.phoneNumber, '447799342687');
assert.equal(storedHistory.length, 1);
assert.equal(storedHistory[0].recordId, 'tmp@example.com');
assert.equal(storedHistory[0].finalStatus, 'success');
});
test('account run history keeps phone as primary identity when phone signup later binds email', async () => {
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
let storedHistory = [{
recordId: 'phone:+447700900123',
accountIdentifierType: 'phone',
accountIdentifier: '+447700900123',
phoneNumber: '+447700900123',
email: '',
finalStatus: 'stopped',
finishedAt: '2026-04-17T04:31:00.000Z',
failureDetail: '步骤 2 已使用手机号,流程尚未完成。',
}];
const helpers = api.createAccountRunHistoryHelpers({
chrome: {
storage: {
local: {
get: async () => ({ accountRunHistory: storedHistory }),
set: async (payload) => {
storedHistory = payload.accountRunHistory;
},
},
},
},
getState: async () => ({}),
normalizeAccountRunHistoryHelperBaseUrl: () => '',
});
const record = await helpers.appendAccountRunRecord('success', {
accountIdentifierType: 'phone',
accountIdentifier: '+447700900123',
signupPhoneNumber: '+447700900123',
email: 'bound@example.com',
password: 'secret',
accountRunHistoryHelperBaseUrl: '',
});
assert.equal(record.recordId, 'phone:+447700900123');
assert.equal(record.accountIdentifierType, 'phone');
assert.equal(record.accountIdentifier, '+447700900123');
assert.equal(record.email, 'bound@example.com');
assert.equal(record.phoneNumber, '+447700900123');
assert.equal(storedHistory.length, 1);
assert.equal(storedHistory[0].recordId, 'phone:+447700900123');
assert.equal(storedHistory[0].finalStatus, 'success');
});
test('account run history records preserve Plus and contribution mode flags', () => {
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
const globalScope = {};
+16 -6
View File
@@ -773,8 +773,8 @@ ${bundle}
return { refreshOAuthUrlBeforeStep6 };
`)();
globalThis.addLog = async (message) => {
calls.push({ type: 'log', message });
globalThis.addLog = async (message, level, options) => {
calls.push({ type: 'log', message, level, options });
};
globalThis.contributionOAuthManager = {
async startContributionFlow(options) {
@@ -801,7 +801,12 @@ return { refreshOAuthUrlBeforeStep6 };
assert.equal(oauthUrl, 'https://auth.example.com/oauth?state=oauth-state-001');
assert.deepStrictEqual(calls, [
{ type: 'log', message: '步骤 7contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...' },
{
type: 'log',
message: 'contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...',
level: 'info',
options: { step: 7, stepKey: 'oauth-login' },
},
{
type: 'contribution',
options: {
@@ -839,8 +844,8 @@ ${bundle}
return { refreshOAuthUrlBeforeStep6 };
`)();
globalThis.addLog = async (message) => {
calls.push({ type: 'log', message });
globalThis.addLog = async (message, level, options) => {
calls.push({ type: 'log', message, level, options });
};
globalThis.contributionOAuthManager = {
async startContributionFlow() {
@@ -868,7 +873,12 @@ return { refreshOAuthUrlBeforeStep6 };
assert.equal(oauthUrl, 'https://panel.example.com/oauth');
assert.deepStrictEqual(calls, [
{ type: 'log', message: '步骤 7contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...' },
{
type: 'log',
message: 'contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...',
level: 'info',
options: { step: 7, stepKey: 'oauth-login' },
},
{ type: 'panel' },
{
type: 'step',
@@ -54,6 +54,7 @@ function extractFunction(name) {
const bundle = [
extractFunction('normalizeEmailGenerator'),
extractFunction('normalizeCustomEmailPool'),
extractFunction('normalizeCustomEmailPoolEntryObjects'),
extractFunction('getCustomEmailPool'),
extractFunction('getCustomEmailPoolEmailForRun'),
extractFunction('getCustomMailProviderPool'),
@@ -122,3 +123,18 @@ test('background selects the matching custom provider pool email for the current
assert.equal(api.getCustomMailProviderPoolEmailForRun(state, 3), 'third@example.com');
assert.equal(api.getCustomMailProviderPoolEmailForRun(state, 4), '');
});
test('background derives active custom email pool from structured entries', () => {
const api = createApi();
const state = {
customEmailPoolEntries: [
{ id: 'a', email: 'one@example.com', enabled: true, used: false },
{ id: 'b', email: 'two@example.com', enabled: true, used: true },
{ id: 'c', email: 'three@example.com', enabled: false, used: false },
],
};
assert.deepEqual(api.getCustomEmailPool(state), ['one@example.com']);
assert.equal(api.getCustomEmailPoolEmailForRun(state, 1), 'one@example.com');
assert.equal(api.getCustomEmailPoolEmailForRun(state, 2), '');
});
+119
View File
@@ -19,6 +19,18 @@ const IP_PROXY_FETCH_TIMEOUT_MS = 20000;
const IP_PROXY_SETTINGS_SCOPE = 'regular';
const IP_PROXY_BYPASS_LIST = ['<local>', 'localhost', '127.0.0.1'];
const IP_PROXY_ROUTE_ALL_TRAFFIC = true;
const IP_PROXY_FORCE_DIRECT_HOST_PATTERNS = [
'pm-redirects.stripe.com',
'*.pm-redirects.stripe.com',
'hwork.pro',
'*.hwork.pro',
'auth.openai.com',
'auth0.openai.com',
'accounts.openai.com',
'luckyous.com',
'*.luckyous.com',
];
const IP_PROXY_FORCE_DIRECT_FALLBACK = 'PROXY 127.0.0.1:7897';
const IP_PROXY_ACCOUNT_LIST_ENABLED = ${accountListEnabled ? 'true' : 'false'};
const IP_PROXY_TARGET_HOST_PATTERNS = [
'openai.com',
@@ -32,11 +44,17 @@ ${coreSource}
return {
applyExitRegionExpectation,
buildIpProxyPacScript,
buildIpProxyRoutingStatePatch,
applyTargetReachabilityExpectation,
getAccountModeProxyPoolFromState,
normalizeIpProxyAccountList,
normalizeProxyPoolEntries,
parseProxyExitProbePayload,
parseIpProxyLine,
resolveExitProbeEndpoints,
resolveIpProxyAutoSwitchThreshold,
resolveTargetReachabilityEndpoints,
shouldEnableIpProxyLeakGuardForStatus,
};
`)();
}
@@ -75,6 +93,48 @@ test('IP proxy parser ignores disabled lines and normalizes proxy entries', () =
assert.equal(pool[1].port, 8080);
});
test('IP proxy probe payload parser extracts country from common probe endpoints', () => {
const api = loadIpProxyCore();
assert.deepEqual(
api.parseProxyExitProbePayload('ip=219.104.171.52\nloc=JP\ncolo=NRT', 'text/plain'),
{ ip: '219.104.171.52', region: 'JP' }
);
assert.deepEqual(
api.parseProxyExitProbePayload(JSON.stringify({
ip: '219.104.171.52',
country: 'JP',
city: 'Osaka',
}), 'application/json'),
{ ip: '219.104.171.52', region: 'JP' }
);
assert.deepEqual(
api.parseProxyExitProbePayload(JSON.stringify({
ip: '219.104.171.52',
country_code: 'JP',
country: 'Japan',
}), 'application/json'),
{ ip: '219.104.171.52', region: 'JP' }
);
});
test('IP proxy routing state patch keeps exit probe endpoint for diagnostics', () => {
const api = loadIpProxyCore();
const patch = api.buildIpProxyRoutingStatePatch({
applied: true,
reason: 'applied',
provider: '711proxy',
exitIp: '219.104.171.52',
exitRegion: 'JP',
exitSource: 'page_context',
exitEndpoint: 'https://ipinfo.io/json',
});
assert.equal(patch.ipProxyAppliedExitIp, '219.104.171.52');
assert.equal(patch.ipProxyAppliedExitRegion, 'JP');
assert.equal(patch.ipProxyAppliedExitEndpoint, 'https://ipinfo.io/json');
});
test('711 fixed-account mode applies region and sticky session parameters', () => {
const api = loadIpProxyCore();
const pool = api.getAccountModeProxyPoolFromState({
@@ -112,6 +172,15 @@ test('IP proxy PAC keeps local traffic direct and routes target traffic through
assert.match(pac, /PROXY global\.rotgb\.711proxy\.com:10000/);
assert.match(pac, /chatgpt\.com/);
assert.match(pac, /openai\.com/);
assert.match(pac, /pm-redirects\.stripe\.com/);
assert.match(pac, /hwork\.pro/);
assert.match(pac, /auth\.openai\.com/);
assert.match(pac, /auth0\.openai\.com/);
assert.match(pac, /accounts\.openai\.com/);
assert.match(pac, /luckyous\.com/);
assert.match(pac, /forceDirectPatterns/);
assert.match(pac, /PROXY 127\.0\.0\.1:7897/);
assert.doesNotMatch(pac, /PROXY 127\.0\.0\.1:7897; DIRECT/);
});
test('sidepanel loads IP proxy scripts before sidepanel bootstrap', () => {
@@ -161,3 +230,53 @@ test('711 proxy region mismatch with missing auth challenge keeps routing as war
);
assert.match(String(status.warning || ''), /期望 US,实际 BR/);
});
test('711 sticky session keeps IP probe on ipinfo but separately checks ChatGPT target', () => {
const api = loadIpProxyCore();
assert.deepStrictEqual(
api.resolveExitProbeEndpoints({
provider: '711proxy',
username: 'USER794331-zone-custom-region-TH-session-69381850-sessTime-5',
}),
['https://ipinfo.io/json']
);
assert.deepStrictEqual(api.resolveTargetReachabilityEndpoints(), ['https://chatgpt.com/']);
});
test('target reachability failure turns detected exit IP into connectivity_failed', () => {
const api = loadIpProxyCore();
const status = api.applyTargetReachabilityExpectation({
applied: true,
reason: 'applied',
exitIp: '58.10.48.73',
exitRegion: 'TH',
}, {
reachable: false,
endpoint: 'https://chatgpt.com/',
error: 'target:page_context:https://chatgpt.com/:net::ERR_EMPTY_RESPONSE',
});
assert.equal(status.applied, false);
assert.equal(status.reason, 'connectivity_failed');
assert.match(status.error, /已检测到出口 IP 58\.10\.48\.73 \[TH\]/);
assert.match(status.error, /真实目标 chatgpt\.com 不可达/);
assert.match(status.error, /ERR_EMPTY_RESPONSE/);
});
test('connectivity_failed keeps DNR leak guard off so ChatGPT shows proxy error instead of blocked by extension', () => {
const api = loadIpProxyCore();
assert.equal(api.shouldEnableIpProxyLeakGuardForStatus({
enabled: true,
applied: false,
reason: 'connectivity_failed',
}), false);
assert.equal(api.shouldEnableIpProxyLeakGuardForStatus({
enabled: true,
applied: false,
reason: 'missing_proxy_entry',
}), true);
});
@@ -44,5 +44,6 @@ test('logging/status add-phone detection ignores step 2 phone-entry switch failu
true
);
assert.equal(loggingStatus.getLoginAuthStateLabel('phone_verification_page'), '手机验证码页');
assert.equal(loggingStatus.getLoginAuthStateLabel('add_email_page'), '添加邮箱页');
assert.equal(loggingStatus.getLoginAuthStateLabel('oauth_consent_page'), 'OAuth 授权页');
});
+210 -50
View File
@@ -3,6 +3,11 @@ const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background.js', 'utf8');
const messageRouterSource = fs.readFileSync('background/message-router.js', 'utf8');
const messageRouterApi = new Function(
'self',
`${messageRouterSource}; return self.MultiPageBackgroundMessageRouter;`
)({});
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
@@ -52,6 +57,44 @@ function extractFunction(name) {
return source.slice(start, end);
}
function createLuckmailPlatformVerifyRouter({
state,
stepKeyByStep = { 10: 'platform-verify' },
} = {}) {
let clearedOptions = null;
let usedMarker = null;
const logs = [];
const router = messageRouterApi.createMessageRouter({
addLog: async (message, level) => {
logs.push({ message, level });
},
buildLocalhostCleanupPrefix: () => '',
clearLuckmailRuntimeState: async (options) => {
clearedOptions = options;
},
closeLocalhostCallbackTabs: async () => {},
closeTabsByUrlPrefix: async () => {},
finalizeIcloudAliasAfterSuccessfulFlow: async () => {},
finalizePhoneActivationAfterSuccessfulFlow: async () => {},
getCurrentLuckmailPurchase: (latestState) => latestState.currentLuckmailPurchase,
getState: async () => state,
getStepDefinitionForState: (step) => ({ id: step, key: stepKeyByStep[step] || '' }),
isHotmailProvider: () => false,
isLocalhostOAuthCallbackUrl: () => true,
isLuckmailProvider: (latestState) => latestState.mailProvider === 'luckmail-api',
patchHotmailAccount: async () => {},
setLuckmailPurchaseUsedState: async (purchaseId, used) => {
usedMarker = { purchaseId, used };
},
});
return {
router,
snapshot() {
return { clearedOptions, usedMarker, logs };
},
};
}
test('ensureLuckmailPurchaseForFlow buys openai mailbox and defaults email type to ms_graph', async () => {
const bundle = [
extractFunction('getLuckmailSessionConfig'),
@@ -451,6 +494,9 @@ const PERSISTED_SETTING_DEFAULTS = {
luckmailBaseUrl: DEFAULT_LUCKMAIL_BASE_URL,
luckmailEmailType: DEFAULT_LUCKMAIL_EMAIL_TYPE,
luckmailDomain: '',
luckmailUsedPurchases: {},
luckmailPreserveTagId: 0,
luckmailPreserveTagName: '保留',
};
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
function normalizeLuckmailBaseUrl(value) {
@@ -462,6 +508,18 @@ function normalizeLuckmailEmailType(value) {
? String(value || '').trim()
: DEFAULT_LUCKMAIL_EMAIL_TYPE;
}
function normalizeLuckmailPurchaseId(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? String(Math.floor(numeric)) : '';
}
function normalizeLuckmailUsedPurchases(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.entries(value).reduce((result, [key, used]) => {
const normalizedKey = normalizeLuckmailPurchaseId(key);
if (normalizedKey) result[normalizedKey] = Boolean(used);
return result;
}, {});
}
function resolveLegacyAutoStepDelaySeconds() {
return undefined;
}
@@ -487,6 +545,17 @@ return {
luckmailEmailType: 'ms_imap',
luckmailDomain: 'outlook.com',
});
const statePayload = api.buildPersistentSettingsPayload({
luckmailUsedPurchases: { 88: true, 99: false, bad: true },
luckmailPreserveTagId: '9',
luckmailPreserveTagName: ' 保留邮箱 ',
});
assert.deepStrictEqual(statePayload, {
luckmailUsedPurchases: { 88: true, 99: false },
luckmailPreserveTagId: 9,
luckmailPreserveTagName: '保留邮箱',
});
});
test('listLuckmailPurchasesByProject only keeps openai purchases', async () => {
@@ -726,17 +795,9 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
assert.equal(snapshot.storedPayload.currentPhoneActivation, null);
});
test('handleStepData step 10 marks current LuckMail purchase as used and clears runtime state', async () => {
const bundle = extractFunction('handleStepData');
const factory = new Function(`
let clearedOptions = null;
let usedMarker = null;
const logs = [];
async function closeLocalhostCallbackTabs() {}
async function getState() {
return {
test('message router platform verify marks current LuckMail purchase as used and clears runtime state', async () => {
const { router, snapshot } = createLuckmailPlatformVerifyRouter({
state: {
mailProvider: 'luckmail-api',
currentHotmailAccountId: null,
currentLuckmailPurchase: {
@@ -744,60 +805,159 @@ async function getState() {
email_address: 'demo@outlook.com',
},
email: 'demo@outlook.com',
};
},
});
await router.handleStepData(10, {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
});
const result = snapshot();
assert.deepStrictEqual(result.usedMarker, { purchaseId: 123, used: true });
assert.deepStrictEqual(result.clearedOptions, { clearEmail: true });
assert.equal(result.logs.at(-1).message, '当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。');
});
test('message router marks current LuckMail purchase as used on Plus platform verify step 13', async () => {
const { router, snapshot } = createLuckmailPlatformVerifyRouter({
state: {
plusModeEnabled: true,
mailProvider: 'luckmail-api',
currentHotmailAccountId: null,
currentLuckmailPurchase: {
id: 456,
email_address: 'plus@outlook.com',
},
email: 'plus@outlook.com',
},
stepKeyByStep: {
10: 'oauth-login',
13: 'platform-verify',
},
});
await router.handleStepData(10, {});
assert.equal(snapshot().usedMarker, null);
await router.handleStepData(13, {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
});
const result = snapshot();
assert.deepStrictEqual(result.usedMarker, { purchaseId: 456, used: true });
assert.equal(result.logs.some((entry) => /已在本地标记为已用/.test(entry.message)), true);
});
test('setLuckmailPurchaseUsedState persists used map to storage.local so reload keeps it non-reusable', async () => {
const bundle = [
extractFunction('getLuckmailUsedPurchases'),
extractFunction('setLuckmailUsedPurchasesState'),
extractFunction('setLuckmailPurchaseUsedState'),
].join('\n');
const factory = new Function(`
let sessionState = {
luckmailUsedPurchases: { 7: true },
};
let persistentUpdates = [];
let sessionUpdates = [];
let broadcasts = [];
function normalizeLuckmailPurchaseId(value) {
const numeric = Number(value);
return Number.isFinite(numeric) && numeric > 0 ? String(Math.floor(numeric)) : '';
}
function getCurrentLuckmailPurchase(state) {
return state.currentLuckmailPurchase;
function normalizeLuckmailUsedPurchases(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.entries(value).reduce((result, [key, used]) => {
const normalizedKey = normalizeLuckmailPurchaseId(key);
if (normalizedKey) result[normalizedKey] = Boolean(used);
return result;
}, {});
}
function isHotmailProvider() {
return false;
async function getState() {
return { ...sessionState };
}
async function patchHotmailAccount() {}
function isLuckmailProvider(state) {
return state.mailProvider === 'luckmail-api';
async function setPersistentSettings(updates) {
persistentUpdates.push(updates);
}
async function setLuckmailPurchaseUsedState(purchaseId, used) {
usedMarker = { purchaseId, used };
async function setState(updates) {
sessionUpdates.push(updates);
sessionState = { ...sessionState, ...updates };
}
async function clearLuckmailRuntimeState(options) {
clearedOptions = options;
function broadcastDataUpdate(updates) {
broadcasts.push(updates);
}
async function addLog(message, level) {
logs.push({ message, level });
}
function buildLocalhostCleanupPrefix() {
return '';
}
async function closeTabsByUrlPrefix() {}
function shouldUseCustomRegistrationEmail() {
return false;
}
async function setEmailStateSilently() {}
async function setState() {}
function broadcastDataUpdate() {}
function isLocalhostOAuthCallbackUrl() {
return true;
}
async function finalizePhoneActivationAfterSuccessfulFlow() {}
async function finalizeIcloudAliasAfterSuccessfulFlow() {}
${bundle}
return {
handleStepData,
setLuckmailPurchaseUsedState,
snapshot() {
return { clearedOptions, usedMarker, logs };
return { sessionState, persistentUpdates, sessionUpdates, broadcasts };
},
};
`);
const api = factory();
await api.handleStepData(10, {
localhostUrl: 'http://localhost:1455/auth/callback?code=abc&state=xyz',
});
const result = await api.setLuckmailPurchaseUsedState(88, true);
const snapshot = api.snapshot();
assert.deepStrictEqual(snapshot.usedMarker, { purchaseId: 123, used: true });
assert.deepStrictEqual(snapshot.clearedOptions, { clearEmail: true });
assert.equal(snapshot.logs.at(-1).message, '当前 LuckMail 邮箱运行态已清空,下轮将优先复用未用邮箱或重新购买邮箱。');
assert.deepStrictEqual(result, { purchaseId: 88, used: true });
assert.deepStrictEqual(snapshot.sessionState.luckmailUsedPurchases, { 7: true, 88: true });
assert.deepStrictEqual(snapshot.persistentUpdates, [
{ luckmailUsedPurchases: { 7: true, 88: true } },
]);
assert.deepStrictEqual(snapshot.sessionUpdates, [
{ luckmailUsedPurchases: { 7: true, 88: true } },
]);
assert.deepStrictEqual(snapshot.broadcasts, [
{ luckmailUsedPurchases: { 7: true, 88: true } },
]);
});
test('setLuckmailPreserveTagInfo persists tag cache to storage.local', async () => {
const bundle = extractFunction('setLuckmailPreserveTagInfo');
const factory = new Function(`
let persistentUpdates = [];
let sessionUpdates = [];
let broadcasts = [];
const DEFAULT_LUCKMAIL_PRESERVE_TAG_NAME = '保留';
function normalizeLuckmailTags(tags) {
return (Array.isArray(tags) ? tags : []).map((tag) => ({
id: Number(tag?.id) || 0,
name: String(tag?.name || '').trim(),
})).filter((tag) => tag.id > 0 || tag.name);
}
async function setPersistentSettings(updates) {
persistentUpdates.push(updates);
}
async function setState(updates) {
sessionUpdates.push(updates);
}
function broadcastDataUpdate(updates) {
broadcasts.push(updates);
}
${bundle}
return {
setLuckmailPreserveTagInfo,
snapshot() {
return { persistentUpdates, sessionUpdates, broadcasts };
},
};
`);
const api = factory();
const result = await api.setLuckmailPreserveTagInfo({ id: '12', name: ' 保留邮箱 ' });
const expected = {
luckmailPreserveTagId: 12,
luckmailPreserveTagName: '保留邮箱',
};
assert.deepStrictEqual(result, expected);
assert.deepStrictEqual(api.snapshot().persistentUpdates, [expected]);
assert.deepStrictEqual(api.snapshot().sessionUpdates, [expected]);
assert.deepStrictEqual(api.snapshot().broadcasts, [expected]);
});
@@ -10,7 +10,12 @@ function createRouter(overrides = {}) {
const events = {
logs: [],
stepStatuses: [],
stateUpdates: [],
broadcasts: [],
balanceRefreshes: [],
emailStates: [],
signupPhoneStates: [],
signupPhoneSilentStates: [],
finalizePayloads: [],
phoneFinalizations: [],
notifyCompletions: [],
@@ -21,15 +26,17 @@ function createRouter(overrides = {}) {
};
const router = api.createMessageRouter({
addLog: async (message, level) => {
events.logs.push({ message, level });
addLog: async (message, level, options = {}) => {
events.logs.push({ message, level, step: options.step, stepKey: options.stepKey });
},
appendAccountRunRecord: async () => null,
batchUpdateLuckmailPurchases: async () => {},
buildLocalhostCleanupPrefix: () => '',
buildLuckmailSessionSettingsPayload: () => ({}),
buildPersistentSettingsPayload: () => ({}),
broadcastDataUpdate: () => {},
broadcastDataUpdate: (updates) => {
events.broadcasts.push(updates);
},
cancelScheduledAutoRun: async () => {},
checkIcloudSession: async () => {},
clearAutoRunTimerAlarm: async () => {},
@@ -109,13 +116,21 @@ function createRouter(overrides = {}) {
events.emailStates.push(email);
},
setEmailStateSilently: async () => {},
setSignupPhoneState: async (phoneNumber) => {
events.signupPhoneStates.push(phoneNumber);
},
setSignupPhoneStateSilently: async (phoneNumber) => {
events.signupPhoneSilentStates.push(phoneNumber);
},
setIcloudAliasPreservedState: async () => {},
setIcloudAliasUsedState: async () => {},
setLuckmailPurchaseDisabledState: async () => {},
setLuckmailPurchasePreservedState: async () => {},
setLuckmailPurchaseUsedState: async () => {},
setPersistentSettings: async () => {},
setState: async () => {},
setState: async (updates) => {
events.stateUpdates.push(updates);
},
setStepStatus: async (step, status) => {
events.stepStatuses.push({ step, status });
},
@@ -126,6 +141,10 @@ function createRouter(overrides = {}) {
testHotmailAccountMailAccess: async () => {},
upsertHotmailAccount: async () => {},
verifyHotmailAccount: async () => {},
refreshGpcCardBalance: overrides.refreshGpcCardBalance || (async (state, options) => {
events.balanceRefreshes.push({ state, options });
return { balance: '余额 3' };
}),
});
return { router, events };
@@ -143,7 +162,51 @@ test('message router skips step 3 when step 2 lands on verification page', async
assert.deepStrictEqual(events.emailStates, ['user@example.com']);
assert.deepStrictEqual(events.stepStatuses, [{ step: 3, status: 'skipped' }]);
assert.equal(events.logs[0]?.message, '步骤 2:提交邮箱后页面直接进入邮箱验证码页,已自动跳过步骤 3。');
assert.equal(events.logs[0]?.message, '步骤 2:提交邮箱后页面直接进入验证码页,已自动跳过步骤 3。');
});
test('message router syncs signup phone runtime state from step 2 payload immediately', async () => {
const { router, events } = createRouter({
state: { stepStatuses: { 3: 'pending' } },
});
await router.handleStepData(2, {
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
signupPhoneNumber: '66959916439',
});
assert.deepStrictEqual(events.emailStates, []);
assert.deepStrictEqual(events.signupPhoneSilentStates, ['66959916439']);
assert.deepStrictEqual(events.signupPhoneStates, []);
});
test('message router clears stale signup phone runtime when step 2 resolves email identity', async () => {
const { router, events } = createRouter({
state: {
stepStatuses: { 3: 'pending' },
accountIdentifierType: 'phone',
accountIdentifier: '+66959916439',
signupPhoneNumber: '+66959916439',
signupPhoneActivation: { activationId: 'old', phoneNumber: '+66959916439' },
},
});
await router.handleStepData(2, {
email: 'user@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'user@example.com',
});
assert.deepStrictEqual(events.emailStates, ['user@example.com']);
assert.deepStrictEqual(events.signupPhoneSilentStates, [null]);
assert.ok(events.stateUpdates.some((updates) => (
updates.accountIdentifierType === 'email'
&& updates.accountIdentifier === 'user@example.com'
&& updates.signupPhoneNumber === ''
&& updates.signupPhoneActivation === null
&& updates.signupPhoneCompletedActivation === null
)));
});
test('message router does not overwrite a completed step 3 when step 2 is replayed', async () => {
@@ -264,7 +327,23 @@ test('message router finalizes step 3 before marking it completed', async () =>
},
},
]);
assert.deepStrictEqual(response, { ok: true });
assert.deepStrictEqual(response, { ok: true });
});
test('message router saves runtime signup phone from sidepanel message', async () => {
const { router, events } = createRouter();
const response = await router.handleMessage({
type: 'SAVE_SIGNUP_PHONE',
source: 'sidepanel',
payload: {
phoneNumber: '66959916439',
},
}, {});
assert.deepStrictEqual(events.signupPhoneStates, ['66959916439']);
assert.deepStrictEqual(events.signupPhoneSilentStates, []);
assert.deepStrictEqual(response, { ok: true, phoneNumber: '66959916439' });
});
test('message router finalizes pending phone activation on platform verify success', async () => {
@@ -352,7 +431,7 @@ test('message router marks step 3 failed when post-submit finalize fails', async
error: '步骤 3 提交后仍停留在密码页。',
},
]);
assert.equal(events.logs.some(({ message }) => /步骤 3 失败步骤 3 提交后仍停留在密码页/.test(message)), true);
assert.equal(events.logs.some(({ message, step }) => /失败步骤 3 提交后仍停留在密码页/.test(message) && step === 3), true);
assert.deepStrictEqual(response, { ok: true, error: '步骤 3 提交后仍停留在密码页。' });
});
@@ -398,3 +477,55 @@ test('message router blocks manual step 4 execution when signup page tab is miss
assert.deepStrictEqual(events.invalidations, []);
assert.deepStrictEqual(events.executedSteps, []);
});
test('message router resolves GPC OTP manual confirmation without completing step early', async () => {
const state = {
plusManualConfirmationPending: true,
plusManualConfirmationRequestId: 'otp-request-1',
plusManualConfirmationStep: 7,
plusManualConfirmationMethod: 'gopay-otp',
};
const { router, events } = createRouter({ state });
const response = await router.handleMessage({
type: 'RESOLVE_PLUS_MANUAL_CONFIRMATION',
source: 'sidepanel',
payload: {
step: 7,
requestId: 'otp-request-1',
confirmed: true,
otp: ' 12-34 56 ',
},
}, {});
assert.deepStrictEqual(response, { ok: true });
assert.equal(events.notifyCompletions.length, 0);
assert.equal(events.stepStatuses.length, 0);
assert.equal(events.stateUpdates[0].gopayHelperResolvedOtp, '123456');
assert.equal(events.stateUpdates[0].plusManualConfirmationPending, false);
assert.deepStrictEqual(events.broadcasts[0], events.stateUpdates[0]);
});
test('message router refreshes GPC balance through explicit sidepanel message', async () => {
const state = {
plusPaymentMethod: 'gpc-helper',
gopayHelperApiUrl: 'http://localhost:18473/',
gopayHelperCardKey: 'state_card',
};
const { router, events } = createRouter({ state });
const response = await router.handleMessage({
type: 'REFRESH_GPC_CARD_BALANCE',
source: 'sidepanel',
payload: {
gopayHelperCardKey: 'payload_card',
reason: 'manual',
},
}, {});
assert.deepStrictEqual(response, { ok: true, balance: '余额 3' });
assert.equal(events.balanceRefreshes.length, 1);
assert.equal(events.balanceRefreshes[0].state.gopayHelperApiUrl, 'http://localhost:18473/');
assert.equal(events.balanceRefreshes[0].state.gopayHelperCardKey, 'payload_card');
assert.deepStrictEqual(events.balanceRefreshes[0].options, { reason: 'manual' });
});
@@ -16,6 +16,21 @@ test('navigation utils module exposes a factory', () => {
assert.equal(typeof api?.createNavigationUtils, 'function');
});
test('navigation utils recognize signup password pages for email and phone signup', () => {
const source = fs.readFileSync('background/navigation-utils.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundNavigationUtils;`)(globalScope);
const utils = api.createNavigationUtils({
DEFAULT_CODEX2API_URL: 'http://localhost:8080/admin/accounts',
DEFAULT_SUB2API_URL: 'https://sub.example.com/admin/accounts',
normalizeLocalCpaStep9Mode: (value) => value,
});
assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/create-account/password'), true);
assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/log-in/password'), true);
assert.equal(utils.isSignupPasswordPageUrl('https://auth.openai.com/log-in'), false);
});
test('navigation utils treat 126 mail hosts as part of the shared NetEase mail family', () => {
const source = fs.readFileSync('background/navigation-utils.js', 'utf8');
const globalScope = {};
@@ -30,8 +30,8 @@ test('platform verify module supports codex2api protocol callback exchange', asy
const completed = [];
const logs = [];
const executor = api.createStep10Executor({
addLog: async (message, level = 'info') => {
logs.push({ message, level });
addLog: async (message, level = 'info', options = {}) => {
logs.push({ message, level, step: options.step, stepKey: options.stepKey });
},
chrome: {},
closeConflictingTabsForSource: async () => {},
@@ -63,8 +63,8 @@ test('platform verify module supports codex2api protocol callback exchange', asy
});
assert.deepStrictEqual(logs, [
{ message: '步骤 10正在向 Codex2API 提交回调并创建账号...', level: 'info' },
{ message: '步骤 10OAuth 账号 flow@example.com 添加成功', level: 'ok' },
{ message: '正在向 Codex2API 提交回调并创建账号...', level: 'info', step: 10, stepKey: 'platform-verify' },
{ message: 'OAuth 账号 flow@example.com 添加成功', level: 'ok', step: 10, stepKey: 'platform-verify' },
]);
assert.deepStrictEqual(completed, [
{
@@ -8,8 +8,8 @@ function createDeps(overrides = {}) {
const uiCalls = [];
const deps = {
addLog: async (message, level = 'info') => {
logs.push({ message, level });
addLog: async (message, level = 'info', options = {}) => {
logs.push({ message, level, step: options.step, stepKey: options.stepKey });
},
chrome: {
tabs: {
@@ -91,8 +91,8 @@ test('platform verify module submits CPA callback via management API first', asy
},
]);
assert.deepStrictEqual(logs, [
{ message: '步骤 10正在通过 CPA 管理接口提交回调地址...', level: 'info' },
{ message: '步骤 10CPA API 回调提交成功', level: 'ok' },
{ message: '正在通过 CPA 管理接口提交回调地址...', level: 'info', step: 10, stepKey: 'platform-verify' },
{ message: 'CPA API 回调提交成功', level: 'ok', step: 10, stepKey: 'platform-verify' },
]);
} finally {
globalThis.fetch = originalFetch;
@@ -160,8 +160,10 @@ test('platform verify module fails fast when CPA API submit fails', async () =>
assert.equal(uiCalls.length, 0);
assert.equal(completed.length, 0);
assert.equal(logs[0].message, '步骤 10正在通过 CPA 管理接口提交回调地址...');
assert.match(logs[1].message, /步骤 10CPA 接口提交失败:failed to persist oauth callback/);
assert.equal(logs[0].message, '正在通过 CPA 管理接口提交回调地址...');
assert.equal(logs[0].step, 10);
assert.match(logs[1].message, /CPA 接口提交失败:failed to persist oauth callback/);
assert.equal(logs[1].step, 10);
assert.equal(logs[1].level, 'error');
} finally {
globalThis.fetch = originalFetch;
@@ -237,3 +239,36 @@ test('platform verify module rejects callback when cpa oauth state mismatches',
globalThis.fetch = originalFetch;
}
});
test('platform verify module sends Plus visible step 13 to SUB2API panel', async () => {
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
const sentMessages = [];
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
const { deps } = createDeps({
getPanelMode: () => 'sub2api',
getTabId: async () => 91,
isTabAlive: async () => true,
sendToContentScript: async (sourceName, message, options) => {
sentMessages.push({ sourceName, message, options });
return { ok: true };
},
});
const executor = api.createStep10Executor(deps);
await executor.executeStep10({
panelMode: 'sub2api',
visibleStep: 13,
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
sub2apiUrl: 'https://sub.example/admin/accounts',
sub2apiEmail: 'admin@example.com',
sub2apiPassword: 'secret',
sub2apiSessionId: 'session-1',
sub2apiOAuthState: 'oauth-state',
});
assert.equal(sentMessages.length, 1);
assert.equal(sentMessages[0].sourceName, 'sub2api-panel');
assert.equal(sentMessages[0].message.step, 13);
});
@@ -0,0 +1,106 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) {
throw new Error(`missing function ${name}`);
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) {
signatureEnded = true;
}
} else if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for function ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
test('markCurrentRegistrationAccountUsed uses fresh state when checkout passes stale state', async () => {
const bundle = extractFunction('markCurrentRegistrationAccountUsed');
const factory = new Function(`
const patchCalls = [];
const logs = [];
async function getState() {
return {
mailProvider: 'hotmail',
currentHotmailAccountId: 'hot-1',
email: 'fresh@example.com',
};
}
function isHotmailProvider(state) {
return String(state.mailProvider || '').toLowerCase() === 'hotmail';
}
function isLuckmailProvider() {
return false;
}
function getCurrentLuckmailPurchase() {
return null;
}
async function patchHotmailAccount(id, updates) {
patchCalls.push({ id, updates });
}
async function setLuckmailPurchaseUsedState() {}
async function clearLuckmailRuntimeState() {}
async function patchMail2925Account() {}
async function finalizeIcloudAliasAfterSuccessfulFlow() {
return { handled: false };
}
async function markCurrentCustomEmailPoolEntryUsed() {
return { updated: false };
}
async function addLog(message, level) {
logs.push({ message, level });
}
${bundle}
return { markCurrentRegistrationAccountUsed, patchCalls, logs };
`);
const api = factory();
const result = await api.markCurrentRegistrationAccountUsed({ email: 'stale@example.com' }, {
logPrefix: 'Plus Checkout:当前账号没有免费试用资格',
});
assert.equal(result.updated, true);
assert.equal(api.patchCalls.length, 1);
assert.equal(api.patchCalls[0].id, 'hot-1');
assert.equal(api.patchCalls[0].updates.used, true);
assert.equal(api.logs.some((entry) => /Hotmail 账号已标记为已用/.test(entry.message)), true);
});
@@ -0,0 +1,140 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) {
throw new Error(`missing function ${name}`);
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let i = start; i < source.length; i += 1) {
const ch = source[i];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = i;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for function ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
test('signup method resolution freezes per run and falls back when phone signup is unavailable', async () => {
const api = new Function(`
const SIGNUP_METHOD_EMAIL = 'email';
const SIGNUP_METHOD_PHONE = 'phone';
const DEFAULT_SIGNUP_METHOD = SIGNUP_METHOD_EMAIL;
const logs = [];
let state = {
signupMethod: 'phone',
phoneVerificationEnabled: true,
plusModeEnabled: false,
contributionMode: false,
resolvedSignupMethod: null,
};
async function getState() { return { ...state }; }
async function setState(updates) { state = { ...state, ...updates }; }
async function addLog(message, level = 'info') { logs.push({ message, level }); }
${extractFunction('normalizeSignupMethod')}
${extractFunction('canUsePhoneSignup')}
${extractFunction('resolveSignupMethod')}
${extractFunction('ensureResolvedSignupMethodForRun')}
return {
logs,
get state() { return state; },
setState,
resolveSignupMethod,
ensureResolvedSignupMethodForRun,
};
`)();
assert.equal(api.resolveSignupMethod({ signupMethod: 'phone', phoneVerificationEnabled: true }), 'phone');
assert.equal(api.resolveSignupMethod({ signupMethod: 'phone', phoneVerificationEnabled: false }), 'email');
assert.equal(api.resolveSignupMethod({ signupMethod: 'phone', phoneVerificationEnabled: true, plusModeEnabled: true }), 'email');
assert.equal(api.resolveSignupMethod({ signupMethod: 'email', resolvedSignupMethod: 'phone', phoneVerificationEnabled: false }), 'phone');
assert.equal(await api.ensureResolvedSignupMethodForRun(), 'phone');
assert.equal(api.state.resolvedSignupMethod, 'phone');
await api.setState({ signupMethod: 'email', phoneVerificationEnabled: false });
assert.equal(await api.ensureResolvedSignupMethodForRun(), 'phone');
assert.equal(api.state.resolvedSignupMethod, 'phone');
await api.setState({ resolvedSignupMethod: null, signupMethod: 'phone', phoneVerificationEnabled: false });
assert.equal(await api.ensureResolvedSignupMethodForRun({ force: true }), 'email');
assert.equal(api.state.resolvedSignupMethod, 'email');
assert.equal(api.logs.some((entry) => /固定为邮箱注册/.test(entry.message)), true);
});
test('background step definitions resolve titles from the frozen signup method', () => {
const api = new Function(`
const captured = [];
const PLUS_PAYMENT_METHOD_PAYPAL = 'paypal';
const PLUS_PAYMENT_METHOD_GOPAY = 'gopay';
const PLUS_PAYMENT_METHOD_GPC_HELPER = 'gpc-helper';
const self = {
MultiPageStepDefinitions: {
getSteps(options) {
captured.push(options);
return [{
id: 2,
key: 'submit-signup-email',
title: options.signupMethod === 'phone' ? '注册并输入手机号' : '注册并输入邮箱',
}];
},
},
};
${extractFunction('isPlusModeState')}
${extractFunction('normalizePlusPaymentMethod')}
${extractFunction('normalizeSignupMethod')}
${extractFunction('getSignupMethodForStepDefinitions')}
${extractFunction('getStepDefinitionsForState')}
return {
getCaptured: () => captured.slice(),
getStepDefinitionsForState,
};
`)();
const steps = api.getStepDefinitionsForState({
plusModeEnabled: true,
plusPaymentMethod: 'gopay',
signupMethod: 'email',
resolvedSignupMethod: 'phone',
});
assert.deepEqual(api.getCaptured(), [{
plusModeEnabled: true,
plusPaymentMethod: 'gopay',
signupMethod: 'phone',
}]);
assert.equal(steps[0].title, '注册并输入手机号');
});
@@ -10,6 +10,10 @@ const signupFlowSource = fs.readFileSync('background/signup-flow-helpers.js', 'u
const signupFlowGlobalScope = {};
const signupFlowApi = new Function('self', `${signupFlowSource}; return self.MultiPageSignupFlowHelpers;`)(signupFlowGlobalScope);
const navigationSource = fs.readFileSync('background/navigation-utils.js', 'utf8');
const navigationGlobalScope = {};
const navigationApi = new Function('self', `${navigationSource}; return self.MultiPageBackgroundNavigationUtils;`)(navigationGlobalScope);
test('step 2 completes with password step skipped when landing on email verification page', async () => {
const completedPayloads = [];
@@ -39,6 +43,8 @@ test('step 2 completes with password step skipped when landing on email verifica
step: 2,
payload: {
email: 'user@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'user@example.com',
nextSignupState: 'verification_page',
nextSignupUrl: 'https://auth.openai.com/email-verification',
skippedPasswordStep: true,
@@ -76,6 +82,8 @@ test('step 2 keeps password flow when landing on password page', async () => {
step: 2,
payload: {
email: 'user@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'user@example.com',
nextSignupState: 'password_page',
nextSignupUrl: 'https://auth.openai.com/create-account/password',
skippedPasswordStep: false,
@@ -84,6 +92,238 @@ test('step 2 keeps password flow when landing on password page', async () => {
]);
});
test('step 2 uses phone activation when resolved signup method is phone', async () => {
const completedPayloads = [];
const sequence = [];
const sentPayloads = [];
const activation = {
activationId: 'signup-activation',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
countryLabel: 'Thailand',
successfulUses: 0,
maxUses: 3,
};
const executor = step2Api.createStep2Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
ensureSignupEntryPageReady: async () => ({ tabId: 14 }),
ensureSignupPostEmailPageReadyInTab: async () => {
throw new Error('email landing helper should not be used for phone signup');
},
ensureSignupPostIdentityPageReadyInTab: async () => ({
state: 'phone_verification_page',
url: 'https://auth.openai.com/phone-verification',
}),
getTabId: async () => 14,
isTabAlive: async () => true,
phoneVerificationHelpers: {
prepareSignupPhoneActivation: async () => {
sequence.push('prepareSignupPhoneActivation');
return activation;
},
cancelSignupPhoneActivation: async () => {
throw new Error('activation should not be cancelled on success');
},
},
resolveSignupMethod: () => 'phone',
resolveSignupEmailForFlow: async () => {
throw new Error('email resolver should not run for phone signup');
},
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'ENSURE_SIGNUP_PHONE_ENTRY_READY') {
sequence.push('ensureSignupPhoneEntryReady');
return {
ready: true,
state: 'phone_entry',
url: 'https://chatgpt.com/',
};
}
sequence.push('submitSignupPhone');
sentPayloads.push(message.payload);
return { submitted: true };
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep2({ signupMethod: 'phone' });
assert.deepStrictEqual(sequence, [
'ensureSignupPhoneEntryReady',
'prepareSignupPhoneActivation',
'submitSignupPhone',
]);
assert.deepStrictEqual(sentPayloads, [
{
signupMethod: 'phone',
phoneNumber: '66959916439',
countryId: 52,
countryLabel: 'Thailand',
},
]);
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
payload: {
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
signupPhoneNumber: '66959916439',
signupPhoneActivation: activation,
nextSignupState: 'phone_verification_page',
nextSignupUrl: 'https://auth.openai.com/phone-verification',
skippedPasswordStep: true,
},
},
]);
});
test('step 2 reuses existing signup phone activation without acquiring a new number', async () => {
const completedPayloads = [];
const sequence = [];
const sentPayloads = [];
const activation = {
activationId: 'existing-signup-activation',
phoneNumber: '+446700000001',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 16,
countryLabel: 'United Kingdom',
};
const executor = step2Api.createStep2Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
ensureSignupEntryPageReady: async () => ({ tabId: 15 }),
ensureSignupPostIdentityPageReadyInTab: async () => ({
state: 'phone_verification_page',
url: 'https://auth.openai.com/phone-verification',
}),
getTabId: async () => 15,
isTabAlive: async () => true,
phoneVerificationHelpers: {
normalizeActivation: (record) => record,
prepareSignupPhoneActivation: async () => {
throw new Error('prepareSignupPhoneActivation should not run when signup activation already exists');
},
cancelSignupPhoneActivation: async () => {
throw new Error('activation should not be cancelled on success');
},
},
resolveSignupMethod: () => 'phone',
resolveSignupEmailForFlow: async () => {
throw new Error('email resolver should not run for phone signup');
},
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'ENSURE_SIGNUP_PHONE_ENTRY_READY') {
sequence.push('ensureSignupPhoneEntryReady');
return { ready: true, state: 'phone_entry' };
}
sequence.push('submitSignupPhone');
sentPayloads.push(message.payload);
return { submitted: true };
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep2({
signupMethod: 'phone',
signupPhoneActivation: activation,
});
assert.deepStrictEqual(sequence, [
'ensureSignupPhoneEntryReady',
'submitSignupPhone',
]);
assert.deepStrictEqual(sentPayloads, [
{
signupMethod: 'phone',
phoneNumber: '+446700000001',
countryId: 16,
countryLabel: 'United Kingdom',
},
]);
assert.equal(completedPayloads[0].payload.signupPhoneActivation, activation);
});
test('step 2 submits manual signup phone without acquiring a number', async () => {
const completedPayloads = [];
const sentPayloads = [];
const executor = step2Api.createStep2Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
completeStepFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
ensureSignupEntryPageReady: async () => ({ tabId: 16 }),
ensureSignupPostIdentityPageReadyInTab: async () => ({
state: 'phone_verification_page',
url: 'https://auth.openai.com/phone-verification',
}),
getTabId: async () => 16,
isTabAlive: async () => true,
phoneVerificationHelpers: {
prepareSignupPhoneActivation: async () => {
throw new Error('prepareSignupPhoneActivation should not run for manual signup phone');
},
},
resolveSignupMethod: () => 'phone',
resolveSignupEmailForFlow: async () => {
throw new Error('email resolver should not run for phone signup');
},
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'ENSURE_SIGNUP_PHONE_ENTRY_READY') {
return { ready: true, state: 'phone_entry' };
}
sentPayloads.push(message.payload);
return { submitted: true };
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep2({
signupMethod: 'phone',
signupPhoneNumber: '+446700000002',
accountIdentifierType: 'phone',
accountIdentifier: '+446700000002',
});
assert.deepStrictEqual(sentPayloads, [
{
signupMethod: 'phone',
phoneNumber: '+446700000002',
countryId: null,
countryLabel: '',
},
]);
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
payload: {
accountIdentifierType: 'phone',
accountIdentifier: '+446700000002',
signupPhoneNumber: '+446700000002',
signupPhoneActivation: null,
nextSignupState: 'phone_verification_page',
nextSignupUrl: 'https://auth.openai.com/phone-verification',
skippedPasswordStep: true,
},
},
]);
});
test('step 2 stops with an explicit error instead of silently skipping 3/4/5 on chatgpt home', async () => {
const completedPayloads = [];
const logs = [];
@@ -126,6 +366,68 @@ test('step 2 stops with an explicit error instead of silently skipping 3/4/5 on
assert.ok(logs.some((item) => /3\/4\/5/.test(item.message)));
});
test('step 2 does not force auth-entry retry on logged-out chatgpt home when content reports entry_home', async () => {
const completedPayloads = [];
const logs = [];
const sentPayloads = [];
let authEntryCalls = 0;
const executor = step2Api.createStep2Executor({
addLog: async (message, level = 'info') => {
logs.push({ message, level });
},
chrome: {
tabs: {
update: async () => {},
get: async () => ({ url: 'https://chatgpt.com/' }),
},
},
completeStepFromBackground: async (step, payload) => {
completedPayloads.push({ step, payload });
},
ensureContentScriptReadyOnTab: async () => {},
ensureSignupAuthEntryPageReady: async () => {
authEntryCalls += 1;
return { tabId: 15 };
},
ensureSignupEntryPageReady: async () => ({ tabId: 15 }),
ensureSignupPostEmailPageReadyInTab: async () => ({
state: 'password_page',
url: 'https://auth.openai.com/create-account/password',
}),
getTabId: async () => 15,
isTabAlive: async () => true,
resolveSignupEmailForFlow: async () => 'user@example.com',
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'ENSURE_SIGNUP_ENTRY_READY') {
return { ready: true, state: 'entry_home', url: 'https://chatgpt.com/' };
}
sentPayloads.push(message.payload);
return { submitted: true };
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep2({ email: 'user@example.com' });
assert.equal(authEntryCalls, 0);
assert.deepStrictEqual(sentPayloads, [{ email: 'user@example.com' }]);
assert.deepStrictEqual(completedPayloads, [
{
step: 2,
payload: {
email: 'user@example.com',
accountIdentifierType: 'email',
accountIdentifier: 'user@example.com',
nextSignupState: 'password_page',
nextSignupUrl: 'https://auth.openai.com/create-account/password',
skippedPasswordStep: false,
},
},
]);
assert.equal(logs.some((item) => /已登录 ChatGPT 首页/.test(item.message)), false);
});
test('signup flow helper recognizes email verification page as post-email landing page', async () => {
let ensureCalls = 0;
let passwordReadyChecks = 0;
@@ -175,6 +477,69 @@ test('signup flow helper recognizes email verification page as post-email landin
assert.equal(passwordReadyChecks, 0);
});
test('signup flow helper accepts phone signup landing on login password page', async () => {
let ensureCalls = 0;
let passwordReadyChecks = 0;
let predicateAcceptedLoginPassword = false;
const navigationUtils = navigationApi.createNavigationUtils({
DEFAULT_CODEX2API_URL: 'http://localhost:8080/admin/accounts',
DEFAULT_SUB2API_URL: 'https://sub.example.com/admin/accounts',
normalizeLocalCpaStep9Mode: (value) => value,
});
const helpers = signupFlowApi.createSignupFlowHelpers({
buildGeneratedAliasEmail: () => '',
chrome: {
tabs: {
get: async () => ({
id: 22,
url: 'https://auth.openai.com/log-in/password',
}),
},
},
ensureContentScriptReadyOnTab: async () => {
ensureCalls += 1;
},
ensureHotmailAccountForFlow: async () => ({}),
ensureLuckmailPurchaseForFlow: async () => ({}),
isGeneratedAliasProvider: () => false,
isHotmailProvider: () => false,
isLuckmailProvider: () => false,
isSignupEmailVerificationPageUrl: navigationUtils.isSignupEmailVerificationPageUrl,
isSignupPasswordPageUrl: (url) => {
const accepted = navigationUtils.isSignupPasswordPageUrl(url);
if (accepted && /\/log-in\/password(?:[/?#]|$)/i.test(url || '')) {
predicateAcceptedLoginPassword = true;
}
return accepted;
},
reuseOrCreateTab: async () => 22,
sendToContentScriptResilient: async (_source, message) => {
assert.equal(message.type, 'ENSURE_SIGNUP_PASSWORD_PAGE_READY');
passwordReadyChecks += 1;
return {};
},
setEmailState: async () => {},
SIGNUP_ENTRY_URL: 'https://chatgpt.com/',
SIGNUP_PAGE_INJECT_FILES: [],
waitForTabUrlMatch: async (_tabId, predicate) => {
const url = 'https://auth.openai.com/log-in/password';
return predicate(url) ? { id: 22, url } : null;
},
});
const result = await helpers.ensureSignupPostIdentityPageReadyInTab(22, 2);
assert.deepStrictEqual(result, {
ready: true,
state: 'password_page',
url: 'https://auth.openai.com/log-in/password',
});
assert.equal(predicateAcceptedLoginPassword, true);
assert.equal(ensureCalls, 1);
assert.equal(passwordReadyChecks, 1);
});
test('signup flow helper reuses existing managed alias email when it is still compatible', async () => {
let buildCalls = 0;
let setEmailCalls = 0;
@@ -214,6 +579,73 @@ test('signup flow helper reuses existing managed alias email when it is still co
assert.equal(setEmailCalls, 0);
});
test('signup flow helper can generate an email on demand when add-email starts from phone signup', async () => {
const fetchedStates = [];
const setStateCalls = [];
const helpers = signupFlowApi.createSignupFlowHelpers({
buildGeneratedAliasEmail: () => '',
chrome: { tabs: { get: async () => ({ id: 21, url: 'https://auth.openai.com/create-account/password' }) } },
ensureContentScriptReadyOnTab: async () => {},
ensureHotmailAccountForFlow: async () => ({}),
ensureLuckmailPurchaseForFlow: async () => ({}),
fetchGeneratedEmail: async (state, options) => {
fetchedStates.push({ state, options });
return 'duck.generated@example.com';
},
isGeneratedAliasProvider: () => false,
isReusableGeneratedAliasEmail: () => false,
isHotmailProvider: () => false,
isLuckmailProvider: () => false,
isSignupEmailVerificationPageUrl: () => false,
isSignupPasswordPageUrl: () => true,
reuseOrCreateTab: async () => 21,
sendToContentScriptResilient: async () => ({}),
setEmailState: async () => {
throw new Error('fetchGeneratedEmail already persists the generated email');
},
setState: async (updates) => {
setStateCalls.push(updates);
},
SIGNUP_ENTRY_URL: 'https://chatgpt.com/',
SIGNUP_PAGE_INJECT_FILES: [],
waitForTabUrlMatch: async () => null,
});
const email = await helpers.resolveSignupEmailForFlow({
email: '',
emailGenerator: 'duck',
accountIdentifierType: 'phone',
accountIdentifier: '+447780579093',
signupPhoneNumber: '+447780579093',
signupPhoneCompletedActivation: {
activationId: 'signup-completed',
phoneNumber: '+447780579093',
},
}, {
preserveAccountIdentity: true,
});
assert.equal(email, 'duck.generated@example.com');
assert.equal(fetchedStates.length, 1);
assert.equal(fetchedStates[0].options.preserveAccountIdentity, true);
assert.deepStrictEqual(setStateCalls, [
{
email: 'duck.generated@example.com',
accountIdentifierType: 'phone',
accountIdentifier: '+447780579093',
signupPhoneNumber: '+447780579093',
signupPhoneActivation: null,
signupPhoneCompletedActivation: {
activationId: 'signup-completed',
phoneNumber: '+447780579093',
},
signupPhoneVerificationRequestedAt: null,
signupPhoneVerificationPurpose: '',
},
]);
});
test('signup flow helper finalizes step 3 submit by reusing signup verification preparation', async () => {
let ensureCalls = 0;
const messages = [];
+15
View File
@@ -8,9 +8,24 @@ test('background imports step registry and shared step definitions', () => {
assert.match(source, /data\/step-definitions\.js/);
assert.match(source, /MultiPageStepDefinitions\?\.getSteps/);
assert.match(source, /getStepRegistryForState\(state\)/);
assert.match(source, /PLUS_PAYPAL_STEP_DEFINITIONS/);
assert.match(source, /PLUS_GOPAY_STEP_DEFINITIONS/);
assert.match(source, /plusPayPalStepRegistry/);
assert.match(source, /plusGoPayStepRegistry/);
assert.match(source, /normalizePlusPaymentMethod\(state\?\.plusPaymentMethod\) === PLUS_PAYMENT_METHOD_GOPAY/);
assert.match(source, /activeStepRegistry\.executeStep\(step,\s*\{/);
assert.match(source, /background\/steps\/create-plus-checkout\.js/);
assert.match(source, /background\/steps\/fill-plus-checkout\.js/);
assert.match(source, /background\/steps\/gopay-manual-confirm\.js/);
assert.match(source, /'gopay-subscription-confirm': \(state\) => goPayManualConfirmExecutor\.executeGoPayManualConfirm\(state\)/);
assert.match(source, /background\/steps\/paypal-approve\.js/);
assert.match(source, /background\/steps\/gopay-approve\.js/);
assert.match(source, /background\/steps\/plus-return-confirm\.js/);
});
test('GoPay approve executor receives debugger click and manual OTP helpers', () => {
const source = fs.readFileSync('background.js', 'utf8');
assert.match(source, /createGoPayApproveExecutor\(\{[\s\S]*clickWithDebugger[\s\S]*requestGoPayOtpInput[\s\S]*\}\)/);
assert.match(source, /REQUEST_GOPAY_OTP_INPUT/);
});
@@ -44,8 +44,159 @@ test('step 3 reuses existing generated password when rerunning the same email fl
source: 'background',
payload: {
email: 'keep@example.com',
phoneNumber: '',
accountIdentifierType: 'email',
accountIdentifier: 'keep@example.com',
password: 'Secret123!',
},
},
]);
});
test('step 3 supports phone-only signup identity when password page is present', async () => {
const events = {
passwordStates: [],
messages: [],
stateUpdates: [],
logs: [],
};
const executor = api.createStep3Executor({
addLog: async (message) => {
events.logs.push(message);
},
chrome: { tabs: { update: async () => {} } },
ensureContentScriptReadyOnTab: async () => {},
generatePassword: () => 'Generated123!',
getTabId: async () => 88,
isTabAlive: async () => true,
sendToContentScript: async (_source, message) => {
events.messages.push(message);
},
setPasswordState: async (password) => {
events.passwordStates.push(password);
},
setState: async (updates) => {
events.stateUpdates.push(updates);
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep3({
email: '',
signupPhoneNumber: '66959916439',
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
customPassword: 'PhoneSecret123!',
accounts: [],
});
assert.deepStrictEqual(events.passwordStates, ['PhoneSecret123!']);
assert.equal(events.logs.some((message) => /注册手机号为 66959916439/.test(message)), true);
assert.equal(events.stateUpdates.length, 1);
assert.deepStrictEqual(events.stateUpdates[0].accounts.map((account) => ({
email: account.email,
phoneNumber: account.phoneNumber,
accountIdentifierType: account.accountIdentifierType,
accountIdentifier: account.accountIdentifier,
})), [
{
email: '',
phoneNumber: '66959916439',
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
},
]);
assert.deepStrictEqual(events.messages, [
{
type: 'EXECUTE_STEP',
step: 3,
source: 'background',
payload: {
email: '',
phoneNumber: '66959916439',
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
password: 'PhoneSecret123!',
},
},
]);
});
test('step 3 phone signup intent does not fall back to a stale email identity', async () => {
const events = {
passwordStates: [],
messages: [],
stateUpdates: [],
};
const executor = api.createStep3Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
ensureContentScriptReadyOnTab: async () => {},
generatePassword: () => 'Generated123!',
getTabId: async () => 88,
isTabAlive: async () => true,
resolveSignupMethod: () => 'phone',
sendToContentScript: async (_source, message) => {
events.messages.push(message);
},
setPasswordState: async (password) => {
events.passwordStates.push(password);
},
setState: async (updates) => {
events.stateUpdates.push(updates);
},
SIGNUP_PAGE_INJECT_FILES: [],
});
await assert.rejects(
() => executor.executeStep3({
email: 'stale@example.com',
signupMethod: 'phone',
resolvedSignupMethod: 'phone',
accountIdentifierType: null,
accountIdentifier: '',
customPassword: 'PhoneSecret123!',
accounts: [],
}),
/缺少注册手机号,请先完成步骤 2 或在侧栏填写注册手机号后再执行步骤 3。/
);
assert.deepStrictEqual(events.passwordStates, []);
assert.deepStrictEqual(events.stateUpdates, []);
assert.deepStrictEqual(events.messages, []);
});
test('step 3 respects resolved email fallback when phone signup is unavailable', async () => {
const events = {
messages: [],
};
const executor = api.createStep3Executor({
addLog: async () => {},
chrome: { tabs: { update: async () => {} } },
ensureContentScriptReadyOnTab: async () => {},
generatePassword: () => 'Generated123!',
getTabId: async () => 88,
isTabAlive: async () => true,
resolveSignupMethod: () => 'email',
sendToContentScript: async (_source, message) => {
events.messages.push(message);
},
setPasswordState: async () => {},
setState: async () => {},
SIGNUP_PAGE_INJECT_FILES: [],
});
await executor.executeStep3({
email: 'fallback@example.com',
signupMethod: 'phone',
resolvedSignupMethod: 'email',
customPassword: 'EmailSecret123!',
accounts: [],
});
assert.equal(events.messages[0].payload.accountIdentifierType, 'email');
assert.equal(events.messages[0].payload.accountIdentifier, 'fallback@example.com');
});
@@ -231,6 +231,82 @@ test('step 4 forwards skipProfileStep when prepare stage already reached logged-
assert.equal(resolveCalls, 0);
});
test('step 4 phone signup branch uses SMS helper and does not poll mailbox', async () => {
const completions = [];
const phoneCalls = [];
let getMailConfigCalls = 0;
let resolveCalls = 0;
const executor = api.createStep4Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
completeStepFromBackground: async (step, payload) => {
completions.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
ensureMail2925MailboxSession: async () => {},
getMailConfig: () => {
getMailConfigCalls += 1;
throw new Error('mail config should not be required for phone signup');
},
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
LUCKMAIL_PROVIDER: 'luckmail-api',
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
phoneVerificationHelpers: {
completeSignupPhoneVerificationFlow: async (tabId, options) => {
phoneCalls.push({ tabId, options });
return {
success: true,
skipProfileStep: true,
skipProfileStepReason: 'combined_verification_profile',
};
},
},
resolveSignupMethod: () => 'phone',
resolveVerificationStep: async () => {
resolveCalls += 1;
},
reuseOrCreateTab: async () => {},
sendToContentScript: async () => ({ ready: true }),
sendToContentScriptResilient: async () => ({ ready: true }),
isRetryableContentScriptTransportError: () => false,
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
throwIfStopped: () => {},
});
await executor.executeStep4({
resolvedSignupMethod: 'phone',
accountIdentifierType: 'phone',
signupPhoneNumber: '66959916439',
signupPhoneActivation: { activationId: 'signup-123', phoneNumber: '66959916439' },
});
assert.equal(getMailConfigCalls, 0);
assert.equal(resolveCalls, 0);
assert.equal(phoneCalls.length, 1);
assert.equal(phoneCalls[0].tabId, 1);
assert.equal(phoneCalls[0].options.state.accountIdentifierType, 'phone');
assert.equal(Object.prototype.hasOwnProperty.call(phoneCalls[0].options, 'signupProfile'), true);
assert.deepStrictEqual(completions, [
{
step: 4,
payload: {
phoneVerification: true,
code: '',
skipProfileStep: true,
skipProfileStepReason: 'combined_verification_profile',
},
},
]);
});
test('step 4 prepare retries transport by recovering retry page without replaying full prepare loop', async () => {
let sendToContentScriptCalls = 0;
let recoverCalls = 0;
+129
View File
@@ -227,6 +227,135 @@ test('step 7 forwards direct OAuth consent skip metadata when completing', async
]);
});
test('step 7 forwards phone login identity payload when account identifier is phone', async () => {
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
const events = {
payloads: [],
};
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async () => {},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
signupPhoneNumber: '66959916439',
signupPhoneCompletedActivation: {
activationId: 'signup-done',
phoneNumber: '66959916439',
countryId: 52,
countryLabel: 'Thailand',
},
password: 'secret',
}),
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
reuseOrCreateTab: async () => {},
sendToContentScriptResilient: async (_sourceName, message) => {
events.payloads.push(message.payload);
return {
step6Outcome: 'success',
state: 'phone_verification_page',
loginVerificationRequestedAt: 123456,
};
},
STEP6_MAX_ATTEMPTS: 3,
throwIfStopped: () => {},
});
await executor.executeStep7({
accountIdentifierType: 'phone',
accountIdentifier: '66959916439',
signupPhoneNumber: '66959916439',
signupPhoneCompletedActivation: {
activationId: 'signup-done',
phoneNumber: '66959916439',
countryId: 52,
countryLabel: 'Thailand',
},
password: 'secret',
});
assert.deepStrictEqual(events.payloads, [
{
email: '',
phoneNumber: '66959916439',
countryId: 52,
countryLabel: 'Thailand',
accountIdentifier: '66959916439',
loginIdentifierType: 'phone',
password: 'secret',
visibleStep: 7,
},
]);
});
test('step 7 can start from a manually filled signup phone without completed step 2 or step 3 state', async () => {
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
const globalScope = {};
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
const events = {
payloads: [],
completions: [],
};
const executor = api.createStep7Executor({
addLog: async () => {},
completeStepFromBackground: async (step, payload) => {
events.completions.push({ step, payload });
},
getErrorMessage: (error) => error?.message || String(error || ''),
getLoginAuthStateLabel: (state) => state || 'unknown',
getState: async () => ({
accountIdentifierType: 'phone',
accountIdentifier: '+447780579093',
signupPhoneNumber: '+447780579093',
stepStatuses: { 2: 'pending', 3: 'pending' },
}),
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
refreshOAuthUrlBeforeStep6: async () => 'https://oauth.example/latest',
reuseOrCreateTab: async () => {},
sendToContentScriptResilient: async (_sourceName, message) => {
events.payloads.push(message.payload);
return {
step6Outcome: 'success',
state: 'phone_verification_page',
loginVerificationRequestedAt: 987654,
};
},
STEP6_MAX_ATTEMPTS: 3,
throwIfStopped: () => {},
});
await executor.executeStep7({
accountIdentifierType: 'phone',
accountIdentifier: '+447780579093',
signupPhoneNumber: '+447780579093',
stepStatuses: { 2: 'pending', 3: 'pending' },
});
assert.equal(events.payloads[0].loginIdentifierType, 'phone');
assert.equal(events.payloads[0].phoneNumber, '+447780579093');
assert.equal(events.payloads[0].email, '');
assert.equal(events.payloads[0].password, '');
assert.deepStrictEqual(events.completions, [
{
step: 7,
payload: {
loginVerificationRequestedAt: 987654,
},
},
]);
});
test('step 7 stops immediately when management secret is missing', async () => {
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
const globalScope = {};
+270 -2
View File
@@ -85,11 +85,273 @@ test('step 8 submits login verification directly without replaying step 7', asyn
{ step8VerificationTargetEmail: 'display.user@example.com' },
]);
assert.deepStrictEqual(calls.ensureReadyOptions, [
{ visibleStep: 8, authLoginStep: 7, timeoutMs: 5000 },
{
visibleStep: 8,
authLoginStep: 7,
allowPhoneVerificationPage: true,
allowAddEmailPage: true,
timeoutMs: 5000,
},
]);
assert.equal(calls.resolveOptions.completionStep, 8);
});
test('step 8 keeps phone-registered accounts on email-code flow when page is email verification', async () => {
const calls = {
getMailConfigCalls: 0,
helperCalls: [],
completions: [],
resolveCalls: 0,
};
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
calls.completions.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: 'phone-user@example.com' }),
getOAuthFlowRemainingMs: async () => 5000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getMailConfig: () => {
calls.getMailConfigCalls += 1;
return {
provider: 'qq',
label: 'QQ 邮箱',
};
},
getState: async () => ({
accountIdentifierType: 'phone',
signupPhoneCompletedActivation: {
activationId: 'signup-done',
phoneNumber: '66959916439',
},
}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
phoneVerificationHelpers: {
completeLoginPhoneVerificationFlow: async (tabId, options) => {
calls.helperCalls.push({ tabId, visibleStep: options.visibleStep, state: options.state });
return { code: '654321' };
},
},
resolveSignupMethod: () => 'phone',
resolveVerificationStep: async () => {
calls.resolveCalls += 1;
},
rerunStep7ForStep8Recovery: async () => {
throw new Error('phone login branch should not rerun step 7 in this test');
},
reuseOrCreateTab: async () => {},
setState: async () => {},
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await executor.executeStep8({
visibleStep: 8,
accountIdentifierType: 'phone',
signupPhoneCompletedActivation: {
activationId: 'signup-done',
phoneNumber: '66959916439',
},
oauthUrl: 'https://oauth.example/latest',
});
assert.equal(calls.getMailConfigCalls, 1);
assert.equal(calls.resolveCalls, 1);
assert.deepStrictEqual(calls.helperCalls, []);
assert.deepStrictEqual(calls.completions, []);
});
test('step 8 routes only a real phone verification page through sms helper', async () => {
const calls = {
getMailConfigCalls: 0,
helperCalls: [],
completions: [],
};
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
completeStepFromBackground: async (step, payload) => {
calls.completions.push({ step, payload });
},
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'phone_verification_page' }),
getOAuthFlowRemainingMs: async () => 5000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getMailConfig: () => {
calls.getMailConfigCalls += 1;
return {
provider: 'qq',
label: 'QQ 邮箱',
};
},
getState: async () => ({}),
getTabId: async () => 1,
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
phoneVerificationHelpers: {
completeLoginPhoneVerificationFlow: async (tabId, options) => {
calls.helperCalls.push({ tabId, visibleStep: options.visibleStep, state: options.state });
return { code: '654321' };
},
},
resolveVerificationStep: async () => {
throw new Error('real phone verification branch should not call email verification flow');
},
rerunStep7ForStep8Recovery: async () => {
throw new Error('real phone verification branch should not rerun step 7 in this test');
},
reuseOrCreateTab: async () => {},
setState: async () => {},
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await executor.executeStep8({
visibleStep: 8,
accountIdentifierType: 'phone',
oauthUrl: 'https://oauth.example/latest',
});
assert.equal(calls.getMailConfigCalls, 0);
assert.deepStrictEqual(calls.helperCalls, [
{
tabId: 1,
visibleStep: 8,
state: {
visibleStep: 8,
accountIdentifierType: 'phone',
oauthUrl: 'https://oauth.example/latest',
},
},
]);
assert.deepStrictEqual(calls.completions, [
{
step: 8,
payload: {
phoneVerification: true,
loginPhoneVerification: true,
code: '654321',
},
},
]);
});
test('step 8 submits add-email before polling the email verification code', async () => {
const calls = {
contentMessages: [],
resolvedStates: [],
setStates: [],
mailStates: [],
};
const executor = api.createStep8Executor({
addLog: async () => {},
chrome: {
tabs: {
update: async () => {},
},
},
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
confirmCustomVerificationStepBypass: async () => {},
ensureStep8VerificationPageReady: async () => ({ state: 'add_email_page', url: 'https://auth.openai.com/add-email' }),
getOAuthFlowRemainingMs: async () => 5000,
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getMailConfig: (state) => {
calls.mailStates.push(state);
return {
provider: 'qq',
label: 'QQ 邮箱',
source: 'mail-qq',
url: 'https://mail.qq.com',
navigateOnReuse: false,
};
},
getState: async () => ({
email: '',
password: 'secret',
oauthUrl: 'https://oauth.example/latest',
}),
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
HOTMAIL_PROVIDER: 'hotmail-api',
isTabAlive: async () => true,
isVerificationMailPollingError: () => false,
LUCKMAIL_PROVIDER: 'luckmail-api',
resolveSignupEmailForFlow: async (state, options = {}) => {
calls.resolvedStates.push(state);
calls.resolveOptions = options;
return 'new.user@example.com';
},
resolveVerificationStep: async (_step, state, _mail, options) => {
calls.resolvedVerification = { state, options };
},
rerunStep7ForStep8Recovery: async () => {},
reuseOrCreateTab: async () => {},
sendToContentScriptResilient: async (_source, message) => {
calls.contentMessages.push(message);
assert.equal(message.type, 'SUBMIT_ADD_EMAIL');
assert.equal(message.payload.email, 'new.user@example.com');
return {
submitted: true,
displayedEmail: 'new.user@example.com',
url: 'https://auth.openai.com/email-verification',
};
},
setState: async (payload) => {
calls.setStates.push(payload);
},
shouldUseCustomRegistrationEmail: () => false,
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
throwIfStopped: () => {},
});
await executor.executeStep8({
visibleStep: 8,
accountIdentifierType: 'phone',
oauthUrl: 'https://oauth.example/latest',
});
assert.equal(calls.contentMessages.length, 1);
assert.equal(calls.resolvedStates.length, 1);
assert.equal(calls.resolveOptions.preserveAccountIdentity, true);
assert.equal(calls.mailStates[0].email, 'new.user@example.com');
assert.equal(calls.resolvedVerification.state.email, 'new.user@example.com');
assert.equal(calls.resolvedVerification.options.targetEmail, 'new.user@example.com');
assert.deepStrictEqual(calls.setStates, [
{
email: 'new.user@example.com',
step8VerificationTargetEmail: 'new.user@example.com',
},
{
step8VerificationTargetEmail: 'new.user@example.com',
},
]);
});
test('Plus login-code step reuses step 8 verification logic but completes visible step 11', async () => {
let resolvedStep = null;
let resolvedOptions = null;
@@ -155,7 +417,13 @@ test('Plus login-code step reuses step 8 verification logic but completes visibl
assert.equal(resolvedStep, 8);
assert.equal(resolvedOptions.completionStep, 11);
assert.equal(resolvedOptions.targetEmail, 'plus.user@example.com');
assert.deepStrictEqual(readyOptions, { visibleStep: 11, authLoginStep: 10, timeoutMs: 9000 });
assert.deepStrictEqual(readyOptions, {
visibleStep: 11,
authLoginStep: 10,
allowPhoneVerificationPage: true,
allowAddEmailPage: true,
timeoutMs: 9000,
});
assert.deepStrictEqual(remainingStepCalls, [11, 11]);
});
+290
View File
@@ -0,0 +1,290 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('phone-sms/providers/five-sim.js', 'utf8');
const api = new Function('self', `${source}; return self.PhoneSmsFiveSimProvider;`)({});
function createTextResponse(payload, ok = true, status = ok ? 200 : 400) {
return {
ok,
status,
text: async () => (typeof payload === 'string' ? payload : JSON.stringify(payload)),
};
}
test('5sim provider fetches profile balance with bearer token', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url, options = {}) => {
requests.push({ url: new URL(url), options });
return createTextResponse({ balance: 123.45, frozen_balance: 6.7, rating: 99 });
},
});
const balance = await provider.fetchBalance({ fiveSimApiKey: 'demo-key' });
assert.equal(requests[0].url.pathname, '/v1/user/profile');
assert.equal(requests[0].options.headers.Authorization, 'Bearer demo-key');
assert.equal(balance.balance, 123.45);
assert.equal(balance.frozenBalance, 6.7);
});
test('5sim provider maps countries and prices', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url, options = {}) => {
const parsed = new URL(url);
requests.push({ url: parsed, options });
if (parsed.pathname === '/v1/guest/countries') {
return createTextResponse({
england: { text_en: 'England', iso: { GB: 1 }, prefix: { 44: 1 } },
indonesia: { text_en: 'Indonesia', iso: { ID: 1 }, prefix: { '+62': 1 } },
thailand: { text_en: 'Thailand', iso: { TH: 1 }, prefix: { '+66': 1 } },
vietnam: { text_en: 'Vietnam', iso: { VN: 1 }, prefix: { '+84': 1 } },
});
}
if (parsed.pathname === '/v1/guest/prices') {
return createTextResponse({ vietnam: { any: { openai: { cost: 10, count: 2 } } } });
}
throw new Error(`unexpected ${parsed.pathname}`);
},
});
const countries = await provider.fetchCountries({});
const prices = await provider.fetchPrices({}, { id: 'vietnam', label: 'Vietnam' });
const entries = provider.collectPriceEntries(prices, []);
assert.equal(countries.length, 4);
assert.equal(countries.some((country) => country.id === 'england'), true);
assert.equal(countries.some((country) => country.id === 'indonesia'), true);
assert.deepStrictEqual(
countries.find((country) => country.id === 'vietnam'),
{
id: 'vietnam',
label: '越南 (Vietnam)',
searchText: 'vietnam 越南 (Vietnam) Vietnam VN +84',
}
);
assert.equal(requests[1].url.searchParams.get('country'), 'vietnam');
assert.equal(requests[1].url.searchParams.get('product'), 'openai');
assert.deepStrictEqual(entries, [{ cost: 10, count: 2, inStock: true }]);
});
test('5sim provider buys, checks, finishes, cancels, bans, and reuses activation', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url, options = {}) => {
const parsed = new URL(url);
requests.push({ url: parsed, options });
if (parsed.pathname === '/v1/guest/products/vietnam/any') {
return createTextResponse({ openai: { Category: 'activation', Qty: 4, Price: 8 } });
}
if (parsed.pathname === '/v1/guest/prices') {
return createTextResponse({ vietnam: { any: { openai: { cost: 9.5, count: 4 } } } });
}
if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') {
return createTextResponse({ id: 1001, phone: '+84901123456', country: 'vietnam', operator: 'any', status: 'PENDING' });
}
if (parsed.pathname === '/v1/user/check/1001') {
return createTextResponse({ id: 1001, phone: '+447911123456', status: 'RECEIVED', sms: [{ text: 'code 112233' }] });
}
if (parsed.pathname === '/v1/user/finish/1001') return createTextResponse({ status: 'FINISHED' });
if (parsed.pathname === '/v1/user/cancel/1001') return createTextResponse({ status: 'CANCELED' });
if (parsed.pathname === '/v1/user/ban/1001') return createTextResponse({ status: 'BANNED' });
if (parsed.pathname === '/v1/user/reuse/openai/84901123456') {
return createTextResponse({ id: 1002, phone: '+84901123456', country: 'vietnam', status: 'PENDING' });
}
throw new Error(`unexpected ${parsed.pathname}`);
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const state = { fiveSimApiKey: 'demo-key', fiveSimCountryId: 'vietnam', fiveSimCountryLabel: '越南 (Vietnam)', fiveSimMaxPrice: '12', fiveSimOperator: 'any' };
const activation = await provider.requestActivation(state);
const code = await provider.pollActivationCode(state, activation, { timeoutMs: 1000, intervalMs: 1, maxRounds: 1 });
await provider.finishActivation(state, activation);
await provider.cancelActivation(state, activation);
await provider.banActivation(state, activation);
const reused = await provider.reuseActivation(state, activation);
assert.equal(activation.provider, '5sim');
assert.equal(activation.activationId, '1001');
assert.equal(activation.countryId, 'vietnam');
assert.equal(code, '112233');
assert.equal(reused.activationId, '1002');
const buy = requests.find((entry) => entry.url.pathname.includes('/buy/activation'));
assert.equal(buy.url.searchParams.get('maxPrice'), '12');
assert.equal(buy.url.searchParams.get('reuse'), '1');
assert.deepStrictEqual(
requests.map((entry) => entry.url.pathname),
[
'/v1/guest/products/vietnam/any',
'/v1/guest/prices',
'/v1/user/buy/activation/vietnam/any/openai',
'/v1/user/check/1001',
'/v1/user/finish/1001',
'/v1/user/cancel/1001',
'/v1/user/ban/1001',
'/v1/user/reuse/openai/84901123456',
]
);
});
test('5sim provider prefers buy-compatible products price over operator detail price', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url, options = {}) => {
const parsed = new URL(url);
requests.push({ url: parsed, options });
if (parsed.pathname === '/v1/guest/products/vietnam/any') {
return createTextResponse({ openai: { Category: 'activation', Qty: 4609, Price: 0.08 } });
}
if (parsed.pathname === '/v1/guest/prices') {
return createTextResponse({
vietnam: {
openai: {
virtual21: { cost: 0.0769, count: 0 },
virtual47: { cost: 0.1282, count: 4608 },
},
},
});
}
if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') {
return createTextResponse({ id: 2001, phone: '+84901234567', country: 'vietnam', operator: 'any', status: 'PENDING' });
}
throw new Error(`unexpected ${parsed.pathname}`);
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const activation = await provider.requestActivation({
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'vietnam',
fiveSimCountryLabel: '越南 (Vietnam)',
fiveSimOperator: 'any',
});
assert.equal(activation.activationId, '2001');
const buy = requests.find((entry) => entry.url.pathname.includes('/buy/activation'));
assert.equal(buy.url.searchParams.get('maxPrice'), '0.08');
assert.deepStrictEqual(
requests.map((entry) => entry.url.pathname),
[
'/v1/guest/products/vietnam/any',
'/v1/guest/prices',
'/v1/user/buy/activation/vietnam/any/openai',
]
);
});
test('5sim provider rejects maxPrice with custom operator before buying', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url) => {
requests.push(url);
throw new Error(`unexpected request ${url}`);
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
() => provider.requestActivation({
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'vietnam',
fiveSimCountryLabel: '瓒婂崡 (Vietnam)',
fiveSimMaxPrice: '12',
fiveSimOperator: 'virtual21',
}),
/maxPrice only works when operator is "any"/
);
assert.deepStrictEqual(requests, []);
});
test('5sim provider reports raw buy payload when HTTP 200 response has no activation', async () => {
const provider = api.createProvider({
fetchImpl: async (url) => {
const parsed = new URL(url);
if (parsed.pathname === '/v1/guest/products/vietnam/any') {
return createTextResponse({ openai: { Category: 'activation', Qty: 10, Price: 0.08 } });
}
if (parsed.pathname === '/v1/guest/prices') {
return createTextResponse({ vietnam: { openai: { virtual47: { cost: 0.1282, count: 10 } } } });
}
if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') {
return createTextResponse({ status: 'no free phones', detail: 'operator unavailable' });
}
throw new Error(`unexpected ${parsed.pathname}`);
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
() => provider.requestActivation({
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'vietnam',
fiveSimCountryLabel: '越南 (Vietnam)',
fiveSimOperator: 'any',
}),
/越南 \(Vietnam\): no free phones/
);
});
test('5sim provider reports purchase rate limit separately from no-number countries', async () => {
const requests = [];
const provider = api.createProvider({
fetchImpl: async (url) => {
const parsed = new URL(url);
requests.push(parsed.pathname);
if (parsed.pathname === '/v1/guest/products/thailand/any') {
return createTextResponse({ openai: { Category: 'activation', Qty: 10, Price: 0.1 } });
}
if (parsed.pathname === '/v1/guest/products/vietnam/any') {
return createTextResponse({ openai: { Category: 'activation', Qty: 10, Price: 0.08 } });
}
if (parsed.pathname === '/v1/guest/prices') {
const country = parsed.searchParams.get('country');
return createTextResponse({ [country]: { any: { openai: { cost: country === 'vietnam' ? 0.08 : 0.1, count: 10 } } } });
}
if (parsed.pathname === '/v1/user/buy/activation/thailand/any/openai') {
return createTextResponse({ status: 'rate limit' });
}
if (parsed.pathname === '/v1/user/buy/activation/vietnam/any/openai') {
return createTextResponse({ status: 'rate limit' });
}
throw new Error(`unexpected ${parsed.pathname}`);
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
() => provider.requestActivation({
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'thailand',
fiveSimCountryLabel: '泰国 (Thailand)',
fiveSimCountryFallback: [{ id: 'vietnam', label: '越南 (Vietnam)' }],
fiveSimOperator: 'any',
}),
(error) => {
assert.match(error.message, /^FIVE_SIM_RATE_LIMIT::/);
assert.match(error.message, /5sim 购买接口触发限流/);
assert.match(error.message, /泰国 \(Thailand\): rate limit/);
assert.match(error.message, /越南 \(Vietnam\): rate limit/);
assert.doesNotMatch(error.message, /均无可用号码/);
return true;
}
);
assert.deepStrictEqual(
requests.filter((pathname) => pathname.includes('/buy/activation')),
[
'/v1/user/buy/activation/thailand/any/openai',
'/v1/user/buy/activation/vietnam/any/openai',
]
);
});
+138
View File
@@ -0,0 +1,138 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('background/steps/gopay-approve.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) throw new Error(`missing function ${name}`);
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let index = start; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') parenDepth += 1;
if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
}
if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
test('GoPay OTP always requests manual confirmation even when a previous code exists', () => {
const body = extractFunction('requestManualGoPayOtp');
assert.doesNotMatch(body, /if\s*\(existingCode\)\s*\{\s*return existingCode;\s*\}/);
assert.match(body, /requestGoPayOtpInput\(\{ code: existingCode \}\)/);
assert.match(body, /检测到上次保存的 GoPay 验证码/);
});
test('GoPay approve handles final payment details iframe as an action frame', () => {
assert.match(source, /GOPAY_PAYMENT_FRAME_URL_PATTERN/);
assert.match(source, /payment\\\/details/);
assert.match(source, /app\\\/challenge/);
assert.match(source, /inspectGoPayFramesByDom/);
assert.match(source, /getGoPayDomFramePriority/);
assert.match(source, /paymentFrames/);
assert.match(source, /frameState\?\.hasPayNowButton/);
assert.match(source, /getGoPayDomFrameKind/);
assert.match(source, /return 'payment'/);
assert.match(source, /sendGoPayFrameCommand\(tabId, actionFrameId, 'GOPAY_CLICK_PAY_NOW'/);
assert.match(source, /getGoPayDebuggerTargets/);
assert.match(source, /chrome\.debugger\.getTargets/);
assert.match(source, /targetId: picked\.targetId/);
assert.match(source, /sendGoPayDebuggerTargetCommand\(actionTargetId, 'GOPAY_CLICK_PAY_NOW'/);
assert.match(source, /sendGoPayDebuggerTargetCommand\(actionTargetId, 'GOPAY_SUBMIT_PIN'/);
assert.match(source, /Input\.insertText/);
assert.match(source, /最终 Bayar 确认/);
});
test('GoPay debugger click does not reuse iframe-relative rects as top-level coordinates', () => {
const body = extractFunction('clickGoPayTargetWithDebugger');
assert.match(body, /Number\.isInteger\(frameId\)/);
assert.match(body, /debugger_click_skipped_for_frame_target/);
assert.ok(
body.indexOf('debugger_click_skipped_for_frame_target') < body.indexOf('clickWithDebugger(tabId, rect)')
);
});
test('GoPay approve treats merchant validate-pin iframe as PIN entry frame', () => {
assert.match(source, /GOPAY_PIN_FRAME_URL_PATTERN/);
assert.match(source, /payment\\\/validate-pin/);
assert.match(source, /kind: 'pin'/);
assert.match(source, /GOPAY_SUBMIT_PIN/);
});
test('GoPay approve closes terminal checkout but does not restart on top-level Pay now alone', () => {
assert.match(source, /GOPAY_RESTART_FROM_STEP6::/);
assert.match(source, /restartGoPayCheckoutFromStep6/);
assert.match(source, /chrome\?\.tabs\?\.remove/);
assert.match(source, /handleGoPayTerminalError\(pageState, tabId\)/);
assert.match(source, /nextState\.hasTerminalError/);
assert.doesNotMatch(source, /GoPay 顶层 Pay now 兜底点击后仍未进入下一步,当前支付会话需要重新创建/);
});
test('GoPay approve falls back to clicking Bayar inside any iframe before top-level Pay now retry', () => {
assert.match(source, /clickGoPayPayButtonInAnyFrame/);
assert.match(source, /data-testid/);
assert.match(source, /pay-button/);
assert.match(source, /已在 GoPay iframe 中点击 Bayar 按钮/);
assert.match(source, /不再自动回退步骤 6/);
});
test('GoPay approve does not treat phone linking page as debugger iframe action', () => {
assert.match(source, /type === 'tel'/);
assert.match(source, /const hasContinueButton = !hasPayNowButton && !hasPhoneInput/);
assert.match(source, /filter\(\(target\) => target\.type === 'iframe'\)/);
});
test('GoPay approve waits and retries slowly on Hubungkan linking page', () => {
assert.match(source, /GOPAY_LINKING_RETRY_WAIT_MS/);
assert.match(source, /GOPAY_LINKING_STABLE_WAIT_MS/);
assert.match(source, /createGoPayStableStateTracker/);
assert.match(source, /clickGoPayContinueBestEffort/);
assert.match(source, /hubungkan\|sambungkan\|tautkan/);
assert.match(source, /先等待 linking 页面加载\/跳转/);
assert.match(source, /改用兜底点击 Hubungkan\/确认按钮/);
assert.doesNotMatch(source, /GoPay 确认按钮点击后页面仍未变化,已暂停自动重复点击。请手动点击页面上的确认按钮,插件会继续等待后续页面。/);
});
test('background auto-run routes GoPay restart sentinel back to step 6', () => {
const backgroundSource = fs.readFileSync('background.js', 'utf8');
assert.match(backgroundSource, /isGoPayCheckoutRestartRequiredFailure/);
assert.match(backgroundSource, /GOPAY_RESTART_FROM_STEP6::/);
assert.match(backgroundSource, /step === 8 && isGoPayCheckoutRestartRequiredFailure\(err\)/);
assert.match(backgroundSource, /step = 6/);
assert.match(backgroundSource, /invalidateDownstreamAfterStepRestart\(5/);
});
test('GoPay approve gives PIN precedence over OTP on ambiguous second PIN pages', () => {
assert.match(source, /pageState\.hasPinInput && !pinSubmitted/);
assert.match(source, /pageState\.hasOtpInput && !pageState\.hasPinInput && !otpSubmitted/);
assert.ok(source.indexOf('pageState.hasPinInput && !pinSubmitted') < source.indexOf('pageState.hasOtpInput && !pageState.hasPinInput && !otpSubmitted'));
assert.doesNotMatch(source, /otp\|one\[-\\s\]\*time\|kode\|verification\|whatsapp\|code\|pin-input-field/);
});
+406
View File
@@ -0,0 +1,406 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const source = fs.readFileSync('content/gopay-flow.js', 'utf8');
function extractFunction(name) {
const markers = [`async function ${name}(`, `function ${name}(`];
const start = markers
.map((marker) => source.indexOf(marker))
.find((index) => index >= 0);
if (start < 0) {
throw new Error(`missing function ${name}`);
}
let parenDepth = 0;
let signatureEnded = false;
let braceStart = -1;
for (let index = start; index < source.length; index += 1) {
const ch = source[index];
if (ch === '(') {
parenDepth += 1;
} else if (ch === ')') {
parenDepth -= 1;
if (parenDepth === 0) signatureEnded = true;
} else if (ch === '{' && signatureEnded) {
braceStart = index;
break;
}
}
if (braceStart < 0) {
throw new Error(`missing body for ${name}`);
}
let depth = 0;
let end = braceStart;
for (; end < source.length; end += 1) {
const ch = source[end];
if (ch === '{') depth += 1;
if (ch === '}') {
depth -= 1;
if (depth === 0) {
end += 1;
break;
}
}
}
return source.slice(start, end);
}
test('GoPay human click helper dispatches pointer and mouse sequence before native click', async () => {
const bundle = [
extractFunction('dispatchPointerMouseSequence'),
extractFunction('humanClickElement'),
].join('\n');
const events = [];
const button = {
tagName: 'BUTTON',
scrollIntoView() { events.push('scroll'); },
focus() { events.push('focus'); },
click() { events.push('native-click'); },
getBoundingClientRect() { return { left: 10, top: 20, width: 100, height: 40 }; },
dispatchEvent(event) {
events.push(event.type);
return true;
},
};
const api = new Function('button', 'events', `
const window = { screenX: 0, screenY: 0 };
class MouseEvent { constructor(type, init = {}) { this.type = type; this.init = init; } }
class PointerEvent extends MouseEvent {}
async function sleep() { events.push('sleep'); }
${bundle}
return { humanClickElement };
`)(button, events);
await api.humanClickElement(button, { beforeMs: 1, afterDispatchMs: 1, afterMs: 1 });
assert.deepEqual(events.slice(0, 3), ['scroll', 'sleep', 'focus']);
assert.ok(events.includes('pointerdown'));
assert.ok(events.includes('mousedown'));
assert.ok(events.includes('mouseup'));
assert.ok(events.includes('click'));
assert.equal(events.at(-2), 'native-click');
});
test('GoPay continue target exposes a debugger-clickable rect', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findContinueButton'),
extractFunction('describeElement'),
extractFunction('getElementClickRect'),
extractFunction('getGoPayContinueTarget'),
].join('\n');
const button = {
tagName: 'BUTTON',
id: 'link-and-pay',
className: 'btn primary',
textContent: 'Link and pay',
innerText: 'Link and pay',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) {
if (name === 'class') return this.className;
return '';
},
getBoundingClientRect() { return { left: 20, top: 30, width: 160, height: 44 }; },
};
const api = new Function('button', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
innerWidth: 390,
innerHeight: 844,
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('[role="button"]') ? [button] : [];
},
};
${bundle}
return { getGoPayContinueTarget };
`)(button);
const target = api.getGoPayContinueTarget();
assert.equal(target.found, true);
assert.equal(target.rect.centerX, 100);
assert.equal(target.rect.centerY, 52);
assert.match(target.target, /Link and pay/);
});
test('GoPay continue target recognizes Indonesian Hubungkan linking button', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findContinueButton'),
extractFunction('describeElement'),
extractFunction('getElementClickRect'),
extractFunction('getGoPayContinueTarget'),
].join('\n');
const button = {
tagName: 'BUTTON',
id: '',
className: 'btn primary',
textContent: 'Hubungkan',
innerText: 'Hubungkan',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) {
if (name === 'class') return this.className;
return '';
},
getBoundingClientRect() { return { left: 40, top: 720, width: 280, height: 48 }; },
};
const termsLink = {
...button,
tagName: 'A',
className: 'terms-link',
textContent: 'Syarat & Ketentuan',
innerText: 'Syarat & Ketentuan',
getBoundingClientRect() { return { left: 120, top: 650, width: 120, height: 16 }; },
};
const api = new Function('button', 'termsLink', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
innerWidth: 390,
innerHeight: 844,
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('a') || selector.includes('[role="button"]') ? [termsLink, button] : [];
},
};
${bundle}
return { findContinueButton, getGoPayContinueTarget };
`)(button, termsLink);
assert.equal(api.findContinueButton(), button);
const target = api.getGoPayContinueTarget();
assert.equal(target.found, true);
assert.match(target.target, /Hubungkan/);
assert.equal(target.rect.centerX, 180);
assert.equal(target.rect.centerY, 744);
});
test('GoPay PIN page detection wins over generic pin-input OTP attributes', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getPageBodyText'),
extractFunction('isGoPayOtpPageText'),
extractFunction('isGoPayPinPageText'),
extractFunction('isGoPayPinInput'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('getVisibleTextInputs'),
extractFunction('isCountrySearchInput'),
extractFunction('getCombinedElementText'),
extractFunction('findInputByPatterns'),
extractFunction('findOtpInput'),
extractFunction('getGoPayPinInputs'),
extractFunction('findPinInput'),
extractFunction('normalizeOtp'),
extractFunction('fillVisibleOtpInputs'),
].join('\n');
const pinInputs = Array.from({ length: 6 }, (_, index) => ({
tagName: 'INPUT',
type: 'text',
id: '',
className: 'pin-input password',
textContent: '',
value: '',
placeholder: '○',
maxLength: 1,
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) {
if (name === 'maxlength') return '1';
if (name === 'data-testid') return `pin-input-${index}`;
if (name === 'class') return this.className;
if (name === 'placeholder') return this.placeholder;
return '';
},
getBoundingClientRect() { return { width: 40, height: 40 }; },
}));
const api = new Function('pinInputs', `
const location = { href: 'https://pin-web-client.gopayapi.com/auth/pin/verify' };
const window = { getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; } };
const document = {
body: { innerText: 'Silakan ketik 6 digit PIN kamu buat lanjut. Lupa PIN', textContent: 'Silakan ketik 6 digit PIN kamu buat lanjut. Lupa PIN' },
querySelectorAll(selector) { return selector.includes('input') ? pinInputs : []; },
};
${bundle}
return { isGoPayOtpPageText, isGoPayPinPageText, findOtpInput, findPinInput, getGoPayPinInputs, fillVisibleOtpInputs };
`)(pinInputs);
assert.equal(api.isGoPayPinPageText(), true);
assert.equal(api.isGoPayOtpPageText(), false);
assert.equal(api.findOtpInput(), null);
assert.equal(api.fillVisibleOtpInputs('123456'), false);
assert.equal(api.findPinInput(), pinInputs[0]);
assert.equal(api.getGoPayPinInputs().length, 6);
});
test('GoPay Pay now button is detected separately from generic continue actions', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
extractFunction('describeElement'),
extractFunction('getElementClickRect'),
extractFunction('getGoPayPayNowTarget'),
].join('\n');
const payButton = {
tagName: 'BUTTON',
id: '',
className: 'btn full primary btn-theme',
textContent: 'Pay now',
innerText: 'Pay now',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) { return name === 'class' ? this.className : ''; },
getBoundingClientRect() { return { left: 411, top: 689, width: 388, height: 38 }; },
};
const refreshButton = {
...payButton,
className: 'refresh-button',
textContent: 'Refresh',
innerText: 'Refresh',
getBoundingClientRect() { return { left: 705, top: 346, width: 90, height: 30 }; },
};
const api = new Function('payButton', 'refreshButton', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
innerWidth: 1280,
innerHeight: 800,
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('[role="button"]') ? [refreshButton, payButton] : [];
},
};
${bundle}
return { findPayNowButton, getGoPayPayNowTarget };
`)(payButton, refreshButton);
assert.equal(api.findPayNowButton(), payButton);
assert.equal(api.getGoPayPayNowTarget().found, true);
assert.match(api.getGoPayPayNowTarget().target, /Pay now/);
});
test('GoPay final Bayar amount button is detected without matching terms link', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
].join('\n');
const bayarButton = {
tagName: 'BUTTON',
className: 'bg-brand text-white',
textContent: 'Bayar\nRp 1',
innerText: 'Bayar\nRp 1',
value: '',
disabled: false,
hidden: false,
parentElement: null,
getAttribute(name) { return name === 'class' ? this.className : ''; },
getBoundingClientRect() { return { left: 16, top: 556, width: 388, height: 44 }; },
};
const termsLink = {
...bayarButton,
tagName: 'A',
className: 'font-semibold text-brand cursor-pointer',
textContent: 'Syarat & Ketentuan',
innerText: 'Syarat & Ketentuan',
getBoundingClientRect() { return { left: 224, top: 608, width: 104, height: 16 }; },
};
const api = new Function('bayarButton', 'termsLink', `
const window = {
getComputedStyle() { return { display: 'block', visibility: 'visible', opacity: '1' }; },
};
const document = {
querySelectorAll(selector) {
return selector.includes('button') || selector.includes('a') || selector.includes('[role="button"]') ? [termsLink, bayarButton] : [];
},
};
${bundle}
return { findPayNowButton };
`)(bayarButton, termsLink);
assert.equal(api.findPayNowButton(), bayarButton);
});
test('GoPay terminal timeout page is reported as retry-required state', () => {
const bundle = [
extractFunction('normalizeText'),
extractFunction('getActionText'),
extractFunction('getPageBodyText'),
extractFunction('isGoPayPinPageText'),
extractFunction('isGoPayPinInput'),
extractFunction('detectGoPayTerminalError'),
extractFunction('isGoPayOtpPageText'),
extractFunction('isVisibleElement'),
extractFunction('getVisibleControls'),
extractFunction('isEnabledControl'),
extractFunction('getVisibleTextInputs'),
extractFunction('findInputByPatterns'),
extractFunction('findPhoneInput'),
extractFunction('isCountrySearchInput'),
extractFunction('findOtpInput'),
extractFunction('getCombinedElementText'),
extractFunction('getGoPayPinInputs'),
extractFunction('findPinInput'),
extractFunction('findClickableByText'),
extractFunction('findPayNowButton'),
extractFunction('findContinueButton'),
extractFunction('readSelectedCountryCodeText'),
extractFunction('inspectGoPayState'),
].join('\n');
const api = new Function(`
const location = { href: 'https://merchants-gws-app.gopayapi.com/app/challenge?reference=test' };
const window = { getComputedStyle() { return { display: 'none', visibility: 'hidden', opacity: '0' }; } };
const document = {
body: { innerText: 'Yah, waktunya habis\\nKalau kamu mau coba lagi, tutup halaman ini dan ulangi prosesnya dari awal, ya.', textContent: '' },
readyState: 'complete',
querySelectorAll() { return []; },
};
${bundle}
return { inspectGoPayState, detectGoPayTerminalError };
`)();
const state = api.inspectGoPayState();
assert.equal(state.hasTerminalError, true);
assert.equal(state.terminalError.code, 'expired');
assert.match(state.terminalError.message, /重新创建 Plus Checkout|超时/);
});
+110
View File
@@ -0,0 +1,110 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadGoPayUtils() {
const source = fs.readFileSync('gopay-utils.js', 'utf8');
const globalScope = {};
return new Function('self', `${source}; return self.GoPayUtils;`)(globalScope);
}
test('GoPay utils normalize manual OTP input', () => {
const api = loadGoPayUtils();
assert.equal(api.normalizeGoPayOtp(' 12-34 56 '), '123456');
assert.equal(api.normalizeGoPayOtp('abc'), '');
});
test('GoPay utils keeps GPC helper payment method distinct', () => {
const api = loadGoPayUtils();
assert.equal(api.normalizePlusPaymentMethod('gpc-helper'), 'gpc-helper');
assert.equal(api.normalizePlusPaymentMethod('gopay'), 'gopay');
assert.equal(api.normalizePlusPaymentMethod('unknown'), 'paypal');
});
test('GoPay utils builds GPC card balance URL from helper endpoints', () => {
const api = loadGoPayUtils();
assert.equal(api.DEFAULT_GPC_HELPER_API_URL, 'https://gopay.hwork.pro');
assert.equal(api.normalizeGpcHelperBaseUrl(''), 'https://gopay.hwork.pro');
assert.equal(
api.buildGpcHelperApiUrl('', '/api/checkout/start'),
'https://gopay.hwork.pro/api/checkout/start'
);
assert.equal(
api.buildGpcCardBalanceUrl('http://localhost:18473/', ' card key/1 '),
'http://localhost:18473/api/card/balance?card_key=card%20key%2F1'
);
assert.equal(
api.buildGpcCardBalanceUrl('https://gopay.hwork.pro/api/checkout/start', 'GPC-1'),
'https://gopay.hwork.pro/api/card/balance?card_key=GPC-1'
);
assert.equal(
api.buildGpcCardBalanceUrl('https://gopay.hwork.pro/api/card/balance?card_key=old', 'new'),
'https://gopay.hwork.pro/api/card/balance?card_key=new'
);
});
test('GoPay utils builds GPC OTP/PIN payloads with card_key and flow_id', () => {
const api = loadGoPayUtils();
assert.deepEqual(
api.buildGpcOtpPayload({
reference_id: ' ref_1 ',
otp: ' 12-34 56 ',
card_key: ' card_1 ',
gopay_guid: ' guid_1 ',
flow_id: ' flow_1 ',
redirect_url: 'https://pm-redirects.stripe.com/test',
}),
{
reference_id: 'ref_1',
otp: '123456',
card_key: 'card_1',
flow_id: 'flow_1',
gopay_guid: 'guid_1',
redirect_url: 'https://pm-redirects.stripe.com/test',
}
);
assert.deepEqual(
api.buildGpcOtpRetryPayload({ referenceId: 'ref_1', otp: '123456', cardKey: 'card_1', flowId: 'flow_1' }),
{
reference_id: 'ref_1',
otp: '123456',
card_key: 'card_1',
flow_id: 'flow_1',
code: '123456',
}
);
assert.deepEqual(
api.buildGpcPinPayload({
referenceId: 'ref_1',
challengeId: 'challenge_1',
gopayGuid: 'guid_1',
pin: '65-43-21',
cardKey: 'card_1',
flowId: 'flow_1',
}),
{
reference_id: 'ref_1',
challenge_id: 'challenge_1',
gopay_guid: 'guid_1',
pin: '654321',
card_key: 'card_1',
flow_id: 'flow_1',
}
);
});
test('GoPay utils formats balance and maps linked-account errors', () => {
const api = loadGoPayUtils();
assert.equal(
api.formatGpcBalancePayload({ remaining_uses: 12, card_status: 'active', flow_id: 'flow_1' }),
'余额 12,状态 activeflow_id flow_1'
);
assert.equal(
api.extractGpcResponseErrorDetail({ errors: [{ loc: ['body', 'otp'], msg: 'Field required' }] }, 422),
'body.otp: Field required'
);
assert.equal(
api.extractGpcResponseErrorDetail({ error_messages: ['account already linked'] }, 406),
'GOPAY已经绑了订阅,需要手动解绑'
);
});
+63
View File
@@ -0,0 +1,63 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
function loadPhoneCountryUtils() {
const source = fs.readFileSync('content/phone-country-utils.js', 'utf8');
const root = {};
return new Function('self', `${source}; return self.MultiPagePhoneCountryUtils;`)(root);
}
test('phone country utils extracts dial codes from current OpenAI country labels', () => {
const utils = loadPhoneCountryUtils();
assert.equal(utils.extractDialCodeFromText('United Kingdom (+44)'), '44');
assert.equal(utils.extractDialCodeFromText('United Kingdom +(44)'), '44');
assert.equal(utils.extractDialCodeFromText('United Kingdom +44'), '44');
});
test('phone country utils resolves country dial code from provider phone numbers', () => {
const utils = loadPhoneCountryUtils();
assert.equal(utils.resolveDialCodeFromPhoneNumber('447423278610'), '44');
assert.equal(utils.resolveDialCodeFromPhoneNumber('+8613800138000'), '86');
assert.equal(utils.resolveDialCodeFromPhoneNumber('12461234567'), '1246');
});
test('phone country utils finds country options by phone dial code and country aliases', () => {
const utils = loadPhoneCountryUtils();
const options = [
{ value: 'ID', textContent: 'Indonesia +(62)' },
{ value: 'GB', textContent: 'United Kingdom +(44)' },
];
assert.equal(utils.findOptionByPhoneNumber(options, '447423278610'), options[1]);
assert.equal(utils.findOptionByCountryLabel(options, 'England'), options[1]);
});
test('phone country utils is loaded before phone auth content scripts', () => {
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
const authScript = manifest.content_scripts.find((entry) => (
Array.isArray(entry.matches)
&& entry.matches.some((match) => match.includes('auth.openai.com'))
));
assert.ok(authScript, 'missing auth content script');
assert.ok(authScript.js.includes('content/phone-country-utils.js'));
assert.ok(
authScript.js.indexOf('content/phone-country-utils.js') < authScript.js.indexOf('content/phone-auth.js'),
'phone-country-utils.js must load before phone-auth.js'
);
assert.ok(
authScript.js.indexOf('content/phone-country-utils.js') < authScript.js.indexOf('content/signup-page.js'),
'phone-country-utils.js must load before signup-page.js'
);
const background = fs.readFileSync('background.js', 'utf8');
const injectLine = background.match(/const\s+SIGNUP_PAGE_INJECT_FILES\s*=\s*\[[^\n]+\]/)?.[0] || '';
assert.ok(injectLine.includes("'content/phone-country-utils.js'"));
assert.ok(
injectLine.indexOf("'content/phone-country-utils.js'") < injectLine.indexOf("'content/phone-auth.js'"),
'dynamic signup injection must load phone-country-utils.js before phone-auth.js'
);
});
+712 -13
View File
@@ -83,6 +83,464 @@ test('phone verification helper requests HeroSMS numbers with fixed OpenAI and T
assert.equal(requests[1].searchParams.get('api_key'), 'demo-key');
});
test('signup phone helper persists signup runtime state without touching add-phone activation', async () => {
const setStateCalls = [];
let currentState = {
heroSmsApiKey: 'demo-key',
currentPhoneActivation: {
activationId: 'add-phone-activation',
phoneNumber: '66880000000',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload(),
};
}
if (action === 'getNumber') {
return {
ok: true,
text: async () => 'ACCESS_NUMBER:signup-123:66959916439',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getState: async () => currentState,
sendToContentScriptResilient: async () => ({}),
setState: async (updates) => {
setStateCalls.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const activation = await helpers.prepareSignupPhoneActivation(currentState);
assert.equal(activation.activationId, 'signup-123');
assert.equal(activation.phoneNumber, '66959916439');
assert.equal(currentState.signupPhoneNumber, '66959916439');
assert.equal(currentState.signupPhoneVerificationPurpose, 'signup');
assert.deepStrictEqual(currentState.signupPhoneActivation, activation);
assert.equal(currentState.accountIdentifierType, 'phone');
assert.equal(currentState.accountIdentifier, '66959916439');
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
});
test('signup phone helper polls signup SMS code and keeps activation purpose isolated', async () => {
const setStateCalls = [];
let currentState = {
heroSmsApiKey: 'demo-key',
phoneCodeWaitSeconds: 15,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 2,
signupPhoneActivation: {
activationId: 'signup-123',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
currentPhoneActivation: {
activationId: 'add-phone-activation',
phoneNumber: '66880000000',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
const action = parsedUrl.searchParams.get('action');
if (action === 'getStatus') {
return {
ok: true,
text: async () => 'STATUS_OK:123456',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getOAuthFlowStepTimeoutMs: async (fallback) => fallback,
getState: async () => currentState,
sendToContentScriptResilient: async () => ({}),
setState: async (updates) => {
setStateCalls.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const code = await helpers.waitForSignupPhoneCode(currentState, currentState.signupPhoneActivation);
assert.equal(code, '123456');
assert.equal(currentState.currentPhoneVerificationCode, '123456');
assert.equal(currentState.signupPhoneNumber, '66959916439');
assert.equal(currentState.signupPhoneVerificationPurpose, 'signup');
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
assert.ok(setStateCalls.some((updates) => updates.signupPhoneVerificationRequestedAt));
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
});
test('signup phone helper finalizes or cancels signup activation without clearing add-phone activation', async () => {
const setStateCalls = [];
const statusActions = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsReuseEnabled: false,
signupPhoneActivation: {
activationId: 'signup-123',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
currentPhoneActivation: {
activationId: 'add-phone-activation',
phoneNumber: '66880000000',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
const action = parsedUrl.searchParams.get('action');
if (action === 'setStatus') {
statusActions.push(parsedUrl.searchParams.get('status'));
return {
ok: true,
text: async () => 'ACCESS_READY',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getState: async () => currentState,
sendToContentScriptResilient: async () => ({}),
setState: async (updates) => {
setStateCalls.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await helpers.finalizeSignupPhoneActivationAfterSuccess(currentState, currentState.signupPhoneActivation);
assert.deepStrictEqual(statusActions, ['6']);
assert.equal(currentState.signupPhoneNumber, '66959916439');
assert.equal(currentState.signupPhoneActivation, null);
assert.deepStrictEqual(currentState.signupPhoneCompletedActivation, {
activationId: 'signup-123',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 1,
maxUses: 3,
});
assert.equal(currentState.signupPhoneVerificationPurpose, '');
assert.equal(currentState.accountIdentifierType, 'phone');
assert.equal(currentState.accountIdentifier, '66959916439');
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
});
test('signup phone helper completes signup SMS verification without touching add-phone activation', async () => {
const setStateCalls = [];
const contentMessages = [];
const statusActions = [];
let currentState = {
heroSmsApiKey: 'demo-key',
heroSmsReuseEnabled: false,
phoneCodeWaitSeconds: 15,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 2,
signupPhoneNumber: '66959916439',
signupPhoneVerificationPurpose: 'signup',
signupPhoneActivation: {
activationId: 'signup-123',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
currentPhoneActivation: {
activationId: 'add-phone-activation',
phoneNumber: '66880000000',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
const action = parsedUrl.searchParams.get('action');
if (action === 'getStatus') {
return {
ok: true,
text: async () => 'STATUS_OK:123456',
};
}
if (action === 'setStatus') {
statusActions.push(parsedUrl.searchParams.get('status'));
return {
ok: true,
text: async () => 'ACCESS_READY',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getOAuthFlowStepTimeoutMs: async (fallback) => fallback,
getState: async () => currentState,
sendToContentScriptResilient: async (_source, message) => {
contentMessages.push(message);
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
return { success: true };
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
setStateCalls.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const result = await helpers.completeSignupPhoneVerificationFlow(77, {
state: currentState,
signupProfile: {
firstName: 'Ada',
lastName: 'Lovelace',
year: 1995,
month: 1,
day: 2,
},
});
assert.deepStrictEqual(result, { success: true });
assert.deepStrictEqual(statusActions, ['6']);
assert.deepStrictEqual(contentMessages.map((message) => ({
type: message.type,
step: message.step,
code: message.payload?.code,
purpose: message.payload?.purpose,
})), [
{
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
step: 4,
code: '123456',
purpose: 'signup',
},
]);
assert.equal(currentState.signupPhoneNumber, '66959916439');
assert.equal(currentState.signupPhoneActivation, null);
assert.equal(currentState.signupPhoneVerificationPurpose, '');
assert.equal(currentState.currentPhoneVerificationCode, '');
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
});
test('signup phone helper completes login SMS verification by reusing the completed signup activation', async () => {
const setStateCalls = [];
const contentMessages = [];
const statusActions = [];
let currentState = {
heroSmsApiKey: 'demo-key',
phoneCodeWaitSeconds: 15,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 2,
signupPhoneCompletedActivation: {
activationId: 'signup-done',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
countryLabel: 'Thailand',
successfulUses: 1,
maxUses: 3,
},
currentPhoneActivation: {
activationId: 'add-phone-activation',
phoneNumber: '66880000000',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
successfulUses: 0,
maxUses: 3,
},
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
const action = parsedUrl.searchParams.get('action');
const id = parsedUrl.searchParams.get('id');
if (action === 'reactivate' && id === 'signup-done') {
return {
ok: true,
text: async () => 'ACCESS_READY',
};
}
if (action === 'getStatus' && id === 'signup-done') {
return {
ok: true,
text: async () => 'STATUS_OK:654321',
};
}
if (action === 'setStatus' && id === 'signup-done') {
statusActions.push(parsedUrl.searchParams.get('status'));
return {
ok: true,
text: async () => 'ACCESS_READY',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}:${id}`);
},
getOAuthFlowStepTimeoutMs: async (fallback) => fallback,
getState: async () => currentState,
sendToContentScriptResilient: async (_source, message) => {
contentMessages.push(message);
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
return { success: true };
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
setStateCalls.push(updates);
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const result = await helpers.completeLoginPhoneVerificationFlow(77, {
state: currentState,
visibleStep: 8,
});
assert.deepStrictEqual(result, { success: true });
assert.deepStrictEqual(statusActions, ['6']);
assert.deepStrictEqual(contentMessages.map((message) => ({
type: message.type,
step: message.step,
code: message.payload?.code,
purpose: message.payload?.purpose,
})), [
{
type: 'SUBMIT_PHONE_VERIFICATION_CODE',
step: 8,
code: '654321',
purpose: 'login',
},
]);
assert.equal(currentState.signupPhoneActivation, null);
assert.equal(currentState.signupPhoneVerificationPurpose, '');
assert.equal(currentState.currentPhoneVerificationCode, '');
assert.deepStrictEqual(currentState.signupPhoneCompletedActivation, {
activationId: 'signup-done',
phoneNumber: '66959916439',
provider: 'hero-sms',
serviceCode: 'dr',
countryId: 52,
countryLabel: 'Thailand',
successfulUses: 2,
maxUses: 3,
});
assert.equal(currentState.currentPhoneActivation.activationId, 'add-phone-activation');
assert.ok(setStateCalls.some((updates) => updates.signupPhoneVerificationPurpose === 'login'));
assert.ok(!setStateCalls.some((updates) => Object.prototype.hasOwnProperty.call(updates, 'currentPhoneActivation')));
});
test('phone verification helper ignores HeroSMS virtual-only stock when physicalCount is zero', async () => {
const requests = [];
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
const action = parsedUrl.searchParams.get('action');
if (action === 'getPrices') {
return {
ok: true,
text: async () => buildHeroSmsPricesPayload({ count: 3, physicalCount: 0, cost: 0.05 }),
};
}
if (action === 'getNumber' || action === 'getNumberV2') {
return {
ok: true,
text: async () => 'NO_NUMBERS',
};
}
throw new Error(`Unexpected HeroSMS action: ${action}`);
},
getState: async () => ({ heroSmsApiKey: 'demo-key' }),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
helpers.requestPhoneActivation({ heroSmsApiKey: 'demo-key', heroSmsActivationRetryRounds: 1 }),
/HeroSMS 已尝试 1 个候选国家,均无可用号码/
);
const actions = requests.map((requestUrl) => `${requestUrl.searchParams.get('action')}:${requestUrl.searchParams.get('maxPrice') || ''}`);
assert.deepStrictEqual(actions, [
'getPrices:',
'getPrices:',
'getPrices:',
'getNumber:',
'getNumberV2:',
'getPrices:',
'getPrices:',
'getPrices:',
'getNumber:',
'getNumberV2:',
]);
});
test('phone verification helper retries HeroSMS getPrices until it receives a usable lowest price', async () => {
const requests = [];
let getPricesAttempt = 0;
@@ -440,11 +898,11 @@ test('phone verification helper retries acquisition rounds when at least one cou
assert.equal(sleeps.length, 1);
assert.equal(sleeps[0], 2000);
assert.equal(
logs.filter((entry) => String(entry.message || '').includes('HeroSMS acquiring phone number')).length >= 2,
logs.filter((entry) => String(entry.message || '').includes('HeroSMS 正在获取手机号')).length >= 2,
true
);
assert.equal(
logs.some((entry) => String(entry.message || '').includes('HeroSMS has no available numbers (round 1/2); retrying')),
logs.some((entry) => String(entry.message || '').includes('HeroSMS 暂无可用号码(第 1/2 轮)')),
true
);
});
@@ -589,6 +1047,7 @@ test('phone verification helper uses HeroSMS getStatusV2 after acquiring a numbe
stateUpdates.some((entry) => entry?.currentPhoneActivation === null && entry?.currentPhoneVerificationCode === ''),
true
);
assert.equal(currentState.phoneNumber, '447911123456');
const actions = requests.map((url) => url.searchParams.get('action'));
assert.deepStrictEqual(actions, [
'getPrices',
@@ -919,6 +1378,43 @@ test('phone verification helper acquires a number from 5sim with fallback countr
assert.equal(requests[3].pathname, '/v1/user/buy/activation/england/any/openai');
});
test('phone verification helper rejects 5sim maxPrice with custom operator before buying', async () => {
const requests = [];
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
requests.push(url);
throw new Error(`Unexpected 5sim request: ${url}`);
},
getState: async () => ({
phoneSmsProvider: '5sim',
fiveSimApiKey: 'five-token',
fiveSimCountryOrder: ['vietnam'],
fiveSimOperator: 'virtual21',
fiveSimMaxPrice: '0.1',
heroSmsActivationRetryRounds: 1,
}),
sendToContentScriptResilient: async () => ({}),
setState: async () => {},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
await assert.rejects(
() => helpers.requestPhoneActivation({
phoneSmsProvider: '5sim',
fiveSimApiKey: 'five-token',
fiveSimCountryOrder: ['vietnam'],
fiveSimOperator: 'virtual21',
fiveSimMaxPrice: '0.1',
heroSmsActivationRetryRounds: 1,
}),
/maxPrice only works when operator is "any"/
);
assert.deepStrictEqual(requests, []);
});
test('phone verification helper honors price-priority ordering for 5sim countries', async () => {
const requests = [];
const helpers = api.createPhoneVerificationHelpers({
@@ -1956,7 +2452,7 @@ test('phone verification helper replaces numbers in step 9 and stops after repla
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
}),
/did not succeed after 3 number replacements/i
/更换 3 次号码后手机号验证仍未成功/
);
assert.ok(statusCallsById['123456'] >= 2, 'first number should be polled twice before being replaced');
assert.ok(messages.includes('SUBMIT_PHONE_NUMBER'));
@@ -2043,7 +2539,7 @@ test('phone verification helper honors timeout-window and poll-round settings be
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
}),
/did not succeed after 1 number replacements/i
/更换 1 次号码后手机号验证仍未成功/
);
assert.equal(messages.includes('RESEND_PHONE_VERIFICATION_CODE'), false);
@@ -2143,7 +2639,7 @@ test('phone verification helper respects configured number replacement limit', a
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
}),
/did not succeed after 1 number replacements/i
/更换 1 次号码后手机号验证仍未成功/
);
const actions = requests.map((requestUrl) => requestUrl.searchParams.get('action'));
@@ -4138,8 +4634,8 @@ test('phone verification helper logs no-supply diagnostics with consecutive stre
};
const helpers = api.createPhoneVerificationHelpers({
addLog: async (message, level = 'info') => {
logs.push({ message, level });
addLog: async (message, level = 'info', options = {}) => {
logs.push({ message, level, options });
},
ensureStep8SignupPageReady: async () => {},
fetchImpl: async (url) => {
@@ -4185,20 +4681,223 @@ test('phone verification helper logs no-supply diagnostics with consecutive stre
await runOnce();
const diagnosticsLogs = logs
.filter((entry) => String(entry.message || '').includes('Step 9 diagnostics: 无号连续失败'))
.map((entry) => String(entry.message || ''));
.filter((entry) => String(entry.message || '').includes('diagnostics: 无号连续失败'));
assert.equal(diagnosticsLogs.length >= 2, true);
assert.equal(diagnosticsLogs.some((message) => message.includes('无号连续失败 1 次')), true);
assert.equal(diagnosticsLogs.some((message) => message.includes('无号连续失败 2 次')), true);
assert.equal(diagnosticsLogs.every((entry) => entry.options?.step === 9), true);
assert.equal(diagnosticsLogs.every((entry) => entry.options?.stepKey === 'phone-verification'), true);
assert.equal(diagnosticsLogs.some((entry) => entry.message.includes('无号连续失败 1 次')), true);
assert.equal(diagnosticsLogs.some((entry) => entry.message.includes('无号连续失败 2 次')), true);
assert.equal(
diagnosticsLogs.some((message) => message.includes('maxPrice=0.06')),
diagnosticsLogs.some((entry) => entry.message.includes('maxPrice=0.06')),
true
);
assert.equal(
diagnosticsLogs.some((message) => message.includes('国家数 HeroSMS=1, 5sim=0, NexSMS=0')),
diagnosticsLogs.some((entry) => entry.message.includes('国家数 HeroSMS=1, 5sim=0, NexSMS=0')),
true
);
assert.equal(currentState.phoneNoSupplyFailureStreak, 2);
assert.equal(requests.some((entry) => entry.searchParams.get('action') === 'getNumber'), true);
});
test('phone verification helper routes 5sim buy, check, and finish by current activation provider', async () => {
const requests = [];
let currentState = {
phoneSmsProvider: '5sim',
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'vietnam',
fiveSimCountryLabel: '越南 (Vietnam)',
fiveSimMaxPrice: '12',
fiveSimOperator: 'any',
verificationResendCount: 0,
phoneVerificationReplacementLimit: 2,
phoneCodeWaitSeconds: 60,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 1,
currentPhoneActivation: null,
reusablePhoneActivation: null,
};
const fiveSimSource = fs.readFileSync('phone-sms/providers/five-sim.js', 'utf8');
const fiveSimModule = new Function('self', `${fiveSimSource}; return self.PhoneSmsFiveSimProvider;`)({});
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
createFiveSimProvider: fiveSimModule.createProvider,
fetchImpl: async (url, options = {}) => {
const parsedUrl = new URL(url);
requests.push({ url: parsedUrl, options });
if (parsedUrl.pathname === '/v1/guest/products/vietnam/any') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ openai: { Category: 'activation', Qty: 3, Price: 9.5 } }),
};
}
if (parsedUrl.pathname === '/v1/guest/prices') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ vietnam: { any: { openai: { cost: 9.5, count: 3 } } } }),
};
}
if (parsedUrl.pathname === '/v1/user/buy/activation/vietnam/any/openai') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ id: 5001, phone: '+84901122334', country: 'vietnam', operator: 'any', status: 'PENDING' }),
};
}
if (parsedUrl.pathname === '/v1/user/check/5001') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ id: 5001, phone: '+447911223344', status: 'RECEIVED', sms: [{ text: 'OpenAI code 123456' }] }),
};
}
if (parsedUrl.pathname === '/v1/user/finish/5001') {
return {
ok: true,
status: 200,
text: async () => JSON.stringify({ status: 'FINISHED' }),
};
}
throw new Error(`Unexpected 5sim path: ${parsedUrl.pathname}`);
},
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'SUBMIT_PHONE_NUMBER') {
return { phoneVerificationPage: true, url: 'https://auth.openai.com/phone-verification' };
}
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
return { success: true, consentReady: true, url: 'https://auth.openai.com/authorize' };
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const result = await helpers.completePhoneVerificationFlow(1, {
addPhonePage: true,
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
});
assert.deepStrictEqual(result, {
success: true,
consentReady: true,
url: 'https://auth.openai.com/authorize',
});
assert.equal(currentState.currentPhoneActivation, null);
assert.equal(currentState.reusablePhoneActivation.provider, '5sim');
assert.equal(currentState.reusablePhoneActivation.activationId, '5001');
assert.equal(currentState.reusablePhoneActivation.successfulUses, 1);
const buy = requests.find((entry) => entry.url.pathname.includes('/buy/activation'));
assert.equal(buy.url.searchParams.get('maxPrice'), '12');
assert.equal(buy.url.searchParams.get('reuse'), '1');
assert.equal(buy.options.headers.Authorization, 'Bearer demo-key');
assert.deepStrictEqual(
requests.map((entry) => entry.url.pathname),
[
'/v1/guest/products/vietnam/any',
'/v1/guest/prices',
'/v1/user/buy/activation/vietnam/any/openai',
'/v1/user/check/5001',
'/v1/user/finish/5001',
]
);
});
test('phone verification helper routes 5sim reusable activation through reuse endpoint', async () => {
const requests = [];
let currentState = {
phoneSmsProvider: '5sim',
fiveSimApiKey: 'demo-key',
fiveSimCountryId: 'vietnam',
fiveSimCountryLabel: '越南 (Vietnam)',
fiveSimOperator: 'any',
verificationResendCount: 0,
phoneVerificationReplacementLimit: 2,
phoneCodeWaitSeconds: 60,
phoneCodeTimeoutWindows: 1,
phoneCodePollIntervalSeconds: 1,
phoneCodePollMaxRounds: 1,
currentPhoneActivation: null,
reusablePhoneActivation: {
activationId: '4001',
phoneNumber: '+84901122334',
provider: '5sim',
serviceCode: 'openai',
countryId: 'vietnam',
countryLabel: '越南 (Vietnam)',
successfulUses: 1,
maxUses: 3,
},
};
const fiveSimSource = fs.readFileSync('phone-sms/providers/five-sim.js', 'utf8');
const fiveSimModule = new Function('self', `${fiveSimSource}; return self.PhoneSmsFiveSimProvider;`)({});
const helpers = api.createPhoneVerificationHelpers({
addLog: async () => {},
ensureStep8SignupPageReady: async () => {},
createFiveSimProvider: fiveSimModule.createProvider,
fetchImpl: async (url) => {
const parsedUrl = new URL(url);
requests.push(parsedUrl);
if (parsedUrl.pathname === '/v1/user/reuse/openai/84901122334') {
return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4002, phone: '+84901122334', country: 'vietnam', status: 'PENDING' }) };
}
if (parsedUrl.pathname === '/v1/user/check/4002') {
return { ok: true, status: 200, text: async () => JSON.stringify({ id: 4002, phone: '+447911223344', status: 'RECEIVED', sms: [{ code: '654321' }] }) };
}
if (parsedUrl.pathname === '/v1/user/finish/4002') {
return { ok: true, status: 200, text: async () => JSON.stringify({ status: 'FINISHED' }) };
}
throw new Error(`Unexpected 5sim path: ${parsedUrl.pathname}`);
},
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => defaultTimeoutMs,
getState: async () => ({ ...currentState }),
sendToContentScriptResilient: async (_source, message) => {
if (message.type === 'SUBMIT_PHONE_NUMBER') {
return { phoneVerificationPage: true, url: 'https://auth.openai.com/phone-verification' };
}
if (message.type === 'SUBMIT_PHONE_VERIFICATION_CODE') {
return { success: true, consentReady: true, url: 'https://auth.openai.com/authorize' };
}
throw new Error(`Unexpected content-script message: ${message.type}`);
},
setState: async (updates) => {
currentState = { ...currentState, ...updates };
},
sleepWithStop: async () => {},
throwIfStopped: () => {},
});
const result = await helpers.completePhoneVerificationFlow(1, {
addPhonePage: true,
phoneVerificationPage: false,
url: 'https://auth.openai.com/add-phone',
});
assert.deepStrictEqual(result, {
success: true,
consentReady: true,
url: 'https://auth.openai.com/authorize',
});
assert.equal(currentState.reusablePhoneActivation.activationId, '4002');
assert.equal(currentState.reusablePhoneActivation.successfulUses, 1);
assert.deepStrictEqual(
requests.map((url) => url.pathname),
[
'/v1/user/reuse/openai/84901122334',
'/v1/user/check/4002',
'/v1/user/finish/4002',
]
);
});

Some files were not shown because too many files have changed in this diff Show More