mirror of
https://github.com/TheNetsky/Microsoft-Rewards-Script.git
synced 2026-01-11 19:06:18 +00:00
Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb3c249d8b | ||
|
|
d76106d3cc | ||
|
|
f5c7ff4907 | ||
|
|
d6d3b05836 | ||
|
|
ba4155bf3f | ||
|
|
03aa7f167f | ||
|
|
a37d60a9df | ||
|
|
2738c85030 | ||
|
|
bd96aeb20c | ||
|
|
996d9485cd | ||
|
|
6b6028bb52 | ||
|
|
368417c5d8 | ||
|
|
49b607d78c | ||
|
|
8c8bdaf3e5 | ||
|
|
7f0da3098d | ||
|
|
398c6db4ad | ||
|
|
169faf2b70 | ||
|
|
8e9c6308d5 | ||
|
|
b2ed289bba | ||
|
|
2c413dad99 | ||
|
|
5aa05804be | ||
|
|
3c1778a7e5 | ||
|
|
b78cea16ae | ||
|
|
a9e5693b71 | ||
|
|
2a8ab7242f | ||
|
|
f2d00225c9 | ||
|
|
abd6117db3 | ||
|
|
4d928d7dd9 | ||
|
|
dc7e122bce | ||
|
|
3e499be8a9 | ||
|
|
554227e200 | ||
|
|
15f62963f8 | ||
|
|
02160a07d9 | ||
|
|
b66114d4dd | ||
|
|
2e80266ad1 | ||
|
|
072e96dd53 | ||
|
|
eea4407454 | ||
|
|
ad2e0b8bb3 | ||
|
|
ad3b215dbc | ||
|
|
f51daf06d6 | ||
|
|
e7c27ac16e | ||
|
|
9f5601d44b | ||
|
|
d5fd06d229 | ||
|
|
0ddc964878 | ||
|
|
325bf65b30 | ||
|
|
6f19bd4b0e | ||
|
|
caf6a42a38 | ||
|
|
352d47229b | ||
|
|
9a12ee1ec8 | ||
|
|
b630c3ddda | ||
|
|
287e3897da | ||
|
|
fcf6aba446 | ||
|
|
1102f2ca94 | ||
|
|
82a896e83f | ||
|
|
b0bd1f52c4 | ||
|
|
7e4121e01b | ||
|
|
849406c44f | ||
|
|
d1f4364e18 | ||
|
|
cf79467a4e | ||
|
|
ae554a0639 | ||
|
|
1a6f9f4ac3 | ||
|
|
45083ed41f | ||
|
|
8b489d50f7 | ||
|
|
7151f2351a | ||
|
|
5afd8cbe1d | ||
|
|
f015649d16 | ||
|
|
034359019c | ||
|
|
09ddbee45a | ||
|
|
7939196e88 | ||
|
|
5bc66c5fc9 | ||
|
|
c70d6f9cb1 | ||
|
|
a47b86e74d | ||
|
|
ce2a72ee36 | ||
|
|
755237caa1 | ||
|
|
2b4cd505c0 | ||
|
|
a39a861dab | ||
|
|
8d19129906 | ||
|
|
c6ab80fe54 | ||
|
|
9b1eed526f | ||
|
|
9a144b2e60 | ||
|
|
28b1881642 | ||
|
|
ef6ad569ff | ||
|
|
da9ba91c5c | ||
|
|
deb2d58b1b | ||
|
|
66a82c2584 | ||
|
|
8a022d5983 | ||
|
|
64048e35d7 | ||
|
|
cf7f7ac790 | ||
|
|
f7aa5039f9 | ||
|
|
e082fb03f0 | ||
|
|
0303b8c605 | ||
|
|
2fea17c415 | ||
|
|
c5beccb54b | ||
|
|
b566ccaece | ||
|
|
15b2b827eb | ||
|
|
02518ee4ba | ||
|
|
69819b5631 | ||
|
|
b389b87792 | ||
|
|
9ea7f5c452 | ||
|
|
f3fb641ecd | ||
|
|
bca1e7c896 | ||
|
|
fd7c8e36d4 | ||
|
|
dcb0c25d46 | ||
|
|
a8cb5482d4 | ||
|
|
28286ff9fe | ||
|
|
1d6167aeca | ||
|
|
3b15fe19a7 |
3
.eslintignore
Normal file
3
.eslintignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist/
|
||||
node_modules/
|
||||
setup/
|
||||
@@ -16,10 +16,7 @@ module.exports = {
|
||||
'@typescript-eslint'
|
||||
],
|
||||
'rules': {
|
||||
'linebreak-style': [
|
||||
'error',
|
||||
'unix'
|
||||
],
|
||||
'linebreak-style': 'off',
|
||||
'quotes': [
|
||||
'error',
|
||||
'single'
|
||||
@@ -28,6 +25,10 @@ module.exports = {
|
||||
'error',
|
||||
'never'
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any':
|
||||
['warn', {
|
||||
fixToUnknown: true // This line is optional and only relevant if you are using TypeScript
|
||||
}],
|
||||
'comma-dangle': 'off',
|
||||
'@typescript-eslint/comma-dangle': 'error',
|
||||
'prefer-arrow-callback': 'error'
|
||||
|
||||
28
.eslintrc.json
Normal file
28
.eslintrc.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"root": true,
|
||||
"env": {
|
||||
"es2021": true,
|
||||
"node": true
|
||||
},
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"project": ["./tsconfig.json"],
|
||||
"sourceType": "module",
|
||||
"ecmaVersion": 2021
|
||||
},
|
||||
"plugins": ["@typescript-eslint", "modules-newline"],
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended"
|
||||
],
|
||||
"rules": {
|
||||
"modules-newline/import-declaration-newline": ["warn", { "count": 3 }],
|
||||
"@typescript-eslint/consistent-type-imports": ["warn", { "prefer": "type-imports" }],
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }],
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"no-console": ["warn", { "allow": ["error", "warn"] }],
|
||||
"quotes": ["error", "double", { "avoidEscape": true }],
|
||||
"linebreak-style": "off"
|
||||
},
|
||||
"ignorePatterns": ["dist/**", "node_modules/**", "setup/**"]
|
||||
}
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.sh text eol=lf
|
||||
*.template text eol=lf
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -1,8 +1,12 @@
|
||||
sessions/
|
||||
dist/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
.vscode/
|
||||
.github/
|
||||
diagnostic/
|
||||
report/
|
||||
accounts.json
|
||||
notes
|
||||
accounts.dev.json
|
||||
accounts.main.json
|
||||
.DS_Store
|
||||
.playwright-chromium-installed
|
||||
20
.vscode/launch.json
vendored
20
.vscode/launch.json
vendored
@@ -1,20 +0,0 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${file}",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/**/*.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
91
Dockerfile
Normal file
91
Dockerfile
Normal file
@@ -0,0 +1,91 @@
|
||||
###############################################################################
|
||||
# Stage 1: Builder
|
||||
###############################################################################
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /usr/src/microsoft-rewards-script
|
||||
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=0
|
||||
|
||||
# Copy package files
|
||||
COPY package.json package-lock.json tsconfig.json ./
|
||||
|
||||
# Install all dependencies required to build the script
|
||||
RUN npm ci --ignore-scripts
|
||||
|
||||
# Copy source and build
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Remove build dependencies, and reinstall only runtime dependencies
|
||||
RUN rm -rf node_modules \
|
||||
&& npm ci --omit=dev --ignore-scripts \
|
||||
&& npm cache clean --force
|
||||
|
||||
# Install Chromium Headless Shell, and cleanup
|
||||
RUN npx playwright install --with-deps --only-shell chromium \
|
||||
&& rm -rf /root/.cache /tmp/* /var/tmp/*
|
||||
|
||||
###############################################################################
|
||||
# Stage 2: Runtime
|
||||
###############################################################################
|
||||
FROM node:22-slim AS runtime
|
||||
|
||||
WORKDIR /usr/src/microsoft-rewards-script
|
||||
|
||||
# Set production environment variables
|
||||
ENV NODE_ENV=production \
|
||||
TZ=UTC \
|
||||
PLAYWRIGHT_BROWSERS_PATH=0 \
|
||||
FORCE_HEADLESS=1
|
||||
|
||||
# Install minimal system libraries required for Chromium headless to run
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
cron \
|
||||
gettext-base \
|
||||
tzdata \
|
||||
ca-certificates \
|
||||
libglib2.0-0 \
|
||||
libdbus-1-3 \
|
||||
libexpat1 \
|
||||
libfontconfig1 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libasound2 \
|
||||
libflac12 \
|
||||
libatk1.0-0 \
|
||||
libatspi2.0-0 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libdav1d6 \
|
||||
libx11-6 \
|
||||
libx11-xcb1 \
|
||||
libxcomposite1 \
|
||||
libxcursor1 \
|
||||
libxdamage1 \
|
||||
libxext6 \
|
||||
libxfixes3 \
|
||||
libxi6 \
|
||||
libxrandr2 \
|
||||
libxrender1 \
|
||||
libxss1 \
|
||||
libxtst6 \
|
||||
libdouble-conversion3 \
|
||||
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives/*
|
||||
|
||||
# Copy compiled application and dependencies from builder stage
|
||||
COPY --from=builder /usr/src/microsoft-rewards-script/dist ./dist
|
||||
COPY --from=builder /usr/src/microsoft-rewards-script/package*.json ./
|
||||
COPY --from=builder /usr/src/microsoft-rewards-script/node_modules ./node_modules
|
||||
|
||||
# Copy runtime scripts with proper permissions and normalize line endings for non-Unix users
|
||||
COPY --chmod=755 src/run_daily.sh ./src/run_daily.sh
|
||||
COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template
|
||||
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh \
|
||||
&& sed -i 's/\r$//' ./src/run_daily.sh
|
||||
|
||||
# Entrypoint handles TZ, initial run toggle, cron templating & launch
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
CMD ["sh", "-c", "echo 'Container started; cron is running.'"]
|
||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
390
README.md
390
README.md
@@ -1,45 +1,353 @@
|
||||
# Microsoft-Rewards-Script
|
||||
Automated Microsoft Rewards script, however this time using TypeScript, Cheerio and Puppeteer.
|
||||
[](https://discord.gg/8BxYbV4pkj)
|
||||
|
||||
Under development, however mainly for personal use!
|
||||
---
|
||||
|
||||
## How to setup ##
|
||||
1. Download or clone source code
|
||||
2. Run `npm i` to install the packages
|
||||
3. Change `accounts.example.json` to `accounts.json` and add your account details
|
||||
4. Change `config.json` to your liking
|
||||
5. Run `npm run build` to build the script
|
||||
6. Run `npm run start` to start the built script
|
||||
## Table of Contents
|
||||
- [Setup](#setup)
|
||||
- [1. Clone the Repository](#1-clone-the-repository)
|
||||
- [2. Copy Configuration Files](#2-copy-configuration-files)
|
||||
- [3. Install Dependencies and Prepare the Browser](#3-install-dependencies-and-prepare-the-browser)
|
||||
- [4. Build and Run](#4-build-and-run)
|
||||
- [Nix Users](#nix-setup)
|
||||
- [Docker Setup](#docker-setup)
|
||||
- [Before Starting](#before-starting)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Example compose.yaml](#example-composeyaml)
|
||||
- [Configuration Reference](#configuration-reference)
|
||||
- [Account Configuration](#account-configuration)
|
||||
- [Features Overview](#features-overview)
|
||||
- [Disclaimer](#disclaimer)
|
||||
|
||||
## Features ##
|
||||
- [x] Multi-Account Support
|
||||
- [x] Session Storing
|
||||
- [x] 2FA Support
|
||||
- [x] Headless Support
|
||||
- [x] Discord Webhook Support
|
||||
- [x] Desktop Searches
|
||||
- [x] Configurable Tasks
|
||||
- [x] Microsoft Edge Searches
|
||||
- [x] Mobile Searches
|
||||
- [x] Emulated Scrolling Support
|
||||
- [x] Emulated Link Clicking Support
|
||||
- [x] Geo Locale Search Queries
|
||||
- [x] Completing Daily Set
|
||||
- [x] Completing More Promotions
|
||||
- [x] Solving Quiz (10 point variant)
|
||||
- [x] Solving Quiz (30-40 point variant)
|
||||
- [x] Completing Click Rewards
|
||||
- [x] Completing Polls
|
||||
- [x] Completing Punchcards
|
||||
- [ ] Solving This Or That Quiz
|
||||
- [x] Clicking Promotional Items
|
||||
- [x] Solving ABC Quiz
|
||||
- [ ] Completing Shopping Game
|
||||
- [ ] Completing Gaming Tab
|
||||
- [x] Clustering Support
|
||||
- [x] Proxy Support
|
||||
---
|
||||
|
||||
## Disclaimer ##
|
||||
Your account may be at risk of getting banned or suspended using this script, you've been warned!
|
||||
<br />
|
||||
Use this script at your own risk!
|
||||
## Setup
|
||||
|
||||
**Requirements:** Node.js ≥ 20 and Git
|
||||
Works on Windows, Linux, macOS, and WSL.
|
||||
|
||||
---
|
||||
|
||||
### 1. Clone the Repository
|
||||
**All systems:**
|
||||
```bash
|
||||
git clone https://github.com/TheNetsky/Microsoft-Rewards-Script.git
|
||||
cd Microsoft-Rewards-Script
|
||||
```
|
||||
Or download the latest release ZIP and extract it.
|
||||
|
||||
---
|
||||
|
||||
### 2. Copy Configuration Files
|
||||
|
||||
**Windows:**
|
||||
Rename manually:
|
||||
```
|
||||
src/accounts.example.json → src/accounts.json
|
||||
```
|
||||
|
||||
**Linux / macOS / WSL:**
|
||||
```bash
|
||||
cp src/accounts.example.json src/accounts.json
|
||||
```
|
||||
|
||||
Then edit:
|
||||
- `src/accounts.json` — fill in your Microsoft account credentials.
|
||||
- `src/config.json` — review or customize options.
|
||||
|
||||
---
|
||||
|
||||
### 3. Install Dependencies and Prepare the Browser
|
||||
|
||||
**All systems:**
|
||||
```bash
|
||||
npm run pre-build
|
||||
```
|
||||
|
||||
This command:
|
||||
- Installs all dependencies
|
||||
- Clears old builds (`dist/`)
|
||||
- Installs Playwright Chromium (required browser)
|
||||
|
||||
---
|
||||
|
||||
### 4. Build and Run
|
||||
|
||||
**All systems:**
|
||||
```bash
|
||||
npm run build
|
||||
npm run start
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Nix Setup
|
||||
|
||||
If using Nix:
|
||||
|
||||
1. Run the pre-build step first:
|
||||
```bash
|
||||
npm run pre-build
|
||||
```
|
||||
|
||||
2. Then start the script:
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
This will launch the script headlessly using `xvfb-run`.
|
||||
|
||||
## Docker Setup
|
||||
|
||||
### Before Starting
|
||||
- Remove local `/node_modules` and `/dist` if previously built.
|
||||
- Remove old Docker volumes if upgrading from older versions.
|
||||
- You can reuse your existing `accounts.json`.
|
||||
|
||||
---
|
||||
|
||||
### Quick Start
|
||||
1. Clone the repository and configure your `accounts.json`.
|
||||
2. Ensure `config.json` has `"headless": true`.
|
||||
3. Edit `compose.yaml`:
|
||||
- Set your timezone (`TZ`)
|
||||
- Set the cron schedule (`CRON_SCHEDULE`)
|
||||
- Optionally enable `RUN_ON_START=true`
|
||||
4. Start the container:
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
5. Monitor logs:
|
||||
```bash
|
||||
docker logs microsoft-rewards-script
|
||||
```
|
||||
|
||||
The container includes a randomized delay (about 5–50 minutes by default)
|
||||
before each scheduled run to appear more natural. This can be configured or disabled via environment variables.
|
||||
|
||||
---
|
||||
|
||||
### Example compose.yaml
|
||||
|
||||
```yaml
|
||||
services:
|
||||
microsoft-rewards-script:
|
||||
image: ghcr.io/your-org/microsoft-rewards-script:latest
|
||||
container_name: microsoft-rewards-script
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
- ./src/accounts.json:/usr/src/microsoft-rewards-script/dist/accounts.json:ro
|
||||
- ./src/config.json:/usr/src/microsoft-rewards-script/dist/config.json:ro
|
||||
- ./sessions:/usr/src/microsoft-rewards-script/dist/sessions
|
||||
# - ./jobstate:/usr/src/microsoft-rewards-script/dist/jobstate
|
||||
|
||||
environment:
|
||||
TZ: "Europe/Amsterdam"
|
||||
NODE_ENV: "production"
|
||||
CRON_SCHEDULE: "0 7,16,20 * * *"
|
||||
RUN_ON_START: "true"
|
||||
# MIN_SLEEP_MINUTES: "5"
|
||||
# MAX_SLEEP_MINUTES: "50"
|
||||
# SKIP_RANDOM: "true"
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: "1.0"
|
||||
memory: "1g"
|
||||
```
|
||||
|
||||
#### compose.yaml Notes
|
||||
- **volumes**
|
||||
- `accounts.json` and `config.json` are mounted read-only to prevent accidental edits.
|
||||
- `sessions` persists login sessions and fingerprints across runs.
|
||||
- If `jobState.enabled` is used, mount its directory as a volume.
|
||||
- **CRON_SCHEDULE**
|
||||
- Uses standard crontab syntax (e.g., via [crontab.guru](https://crontab.guru/)).
|
||||
- Schedule is evaluated inside the container using the configured `TZ`.
|
||||
- **RUN_ON_START**
|
||||
- Runs the script once immediately on startup, then continues on schedule.
|
||||
- **Randomization**
|
||||
- Default delay: 5–50 minutes.
|
||||
- Adjustable via `MIN_SLEEP_MINUTES` and `MAX_SLEEP_MINUTES`, or disable with `SKIP_RANDOM`.
|
||||
|
||||
---
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
Edit `src/config.json` to customize behavior.
|
||||
Below is a summary of key configuration sections.
|
||||
|
||||
### Core
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `baseURL` | Microsoft Rewards base URL | `https://rewards.bing.com` |
|
||||
| `sessionPath` | Folder to store browser sessions | `sessions` |
|
||||
| `dryRun` | Simulate execution without running tasks | `false` |
|
||||
|
||||
### Browser
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `browser.headless` | Run browser invisibly | `false` |
|
||||
| `browser.globalTimeout` | Timeout for actions | `"30s"` |
|
||||
|
||||
### Fingerprinting
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `fingerprinting.saveFingerprint.mobile` | Reuse mobile fingerprint | `true` |
|
||||
| `fingerprinting.saveFingerprint.desktop` | Reuse desktop fingerprint | `true` |
|
||||
|
||||
### Execution
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `execution.parallel` | Run desktop and mobile simultaneously | `false` |
|
||||
| `execution.runOnZeroPoints` | Run even with zero points | `false` |
|
||||
| `execution.clusters` | Number of concurrent account clusters | `1` |
|
||||
|
||||
### Job State
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `jobState.enabled` | Save last job state | `true` |
|
||||
| `jobState.dir` | Directory for job data | `""` |
|
||||
|
||||
### Workers (Tasks)
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `doDailySet` | Complete daily set | `true` |
|
||||
| `doMorePromotions` | Complete more promotions | `true` |
|
||||
| `doPunchCards` | Complete punchcards | `true` |
|
||||
| `doDesktopSearch` | Perform desktop searches | `true` |
|
||||
| `doMobileSearch` | Perform mobile searches | `true` |
|
||||
| `doDailyCheckIn` | Complete daily check-in | `true` |
|
||||
| `doReadToEarn` | Complete Read-to-Earn | `true` |
|
||||
| `bundleDailySetWithSearch` | Combine daily set and searches | `true` |
|
||||
|
||||
### Search
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `search.useLocalQueries` | Use local query list | `true` |
|
||||
| `search.settings.useGeoLocaleQueries` | Use region-based queries | `true` |
|
||||
| `search.settings.scrollRandomResults` | Random scrolling | `true` |
|
||||
| `search.settings.clickRandomResults` | Random link clicking | `true` |
|
||||
| `search.settings.retryMobileSearchAmount` | Retry mobile searches | `2` |
|
||||
| `search.settings.delay.min` | Minimum delay between searches | `1min` |
|
||||
| `search.settings.delay.max` | Maximum delay between searches | `5min` |
|
||||
|
||||
### Query Diversity
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `queryDiversity.enabled` | Enable multiple query sources | `true` |
|
||||
| `queryDiversity.sources` | Query providers | `["google-trends", "reddit", "local-fallback"]` |
|
||||
| `queryDiversity.maxQueriesPerSource` | Limit per source | `10` |
|
||||
| `queryDiversity.cacheMinutes` | Cache lifetime | `30` |
|
||||
|
||||
### Humanization
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `humanization.enabled` | Enable human behavior | `true` |
|
||||
| `stopOnBan` | Stop immediately on ban | `true` |
|
||||
| `immediateBanAlert` | Alert instantly if banned | `true` |
|
||||
| `actionDelay.min` | Minimum delay per action (ms) | `500` |
|
||||
| `actionDelay.max` | Maximum delay per action (ms) | `2200` |
|
||||
| `gestureMoveProb` | Chance of random mouse movement | `0.65` |
|
||||
| `gestureScrollProb` | Chance of random scrolls | `0.4` |
|
||||
|
||||
### Vacation Mode
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `vacation.enabled` | Enable random pauses | `true` |
|
||||
| `minDays` | Minimum days off | `2` |
|
||||
| `maxDays` | Maximum days off | `4` |
|
||||
|
||||
### Risk Management
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `enabled` | Enable risk-based adjustments | `true` |
|
||||
| `autoAdjustDelays` | Adapt delays dynamically | `true` |
|
||||
| `stopOnCritical` | Stop on critical warning | `false` |
|
||||
| `banPrediction` | Predict bans based on signals | `true` |
|
||||
| `riskThreshold` | Risk tolerance level | `75` |
|
||||
|
||||
### Retry Policy
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `maxAttempts` | Maximum retry attempts | `3` |
|
||||
| `baseDelay` | Initial retry delay | `1000` |
|
||||
| `maxDelay` | Maximum retry delay | `30s` |
|
||||
| `multiplier` | Backoff multiplier | `2` |
|
||||
| `jitter` | Random jitter factor | `0.2` |
|
||||
|
||||
### Proxy
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `proxy.proxyGoogleTrends` | Proxy Google Trends requests | `true` |
|
||||
| `proxy.proxyBingTerms` | Proxy Bing terms requests | `true` |
|
||||
|
||||
### Notifications
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `notifications.webhook.enabled` | Enable Discord webhook | `false` |
|
||||
| `notifications.webhook.url` | Discord webhook URL | `""` |
|
||||
| `notifications.conclusionWebhook.enabled` | Enable summary webhook | `false` |
|
||||
| `notifications.conclusionWebhook.url` | Summary webhook URL | `""` |
|
||||
| `notifications.ntfy.enabled` | Enable Ntfy push alerts | `false` |
|
||||
| `notifications.ntfy.url` | Ntfy server URL | `""` |
|
||||
| `notifications.ntfy.topic` | Ntfy topic name | `"rewards"` |
|
||||
|
||||
### Logging
|
||||
| Setting | Description | Default |
|
||||
|----------|-------------|----------|
|
||||
| `excludeFunc` | Exclude from console logs | `["SEARCH-CLOSE-TABS", "LOGIN-NO-PROMPT", "FLOW"]` |
|
||||
| `webhookExcludeFunc` | Exclude from webhook logs | `["SEARCH-CLOSE-TABS", "LOGIN-NO-PROMPT", "FLOW"]` |
|
||||
| `redactEmails` | Hide emails in logs | `true` |
|
||||
|
||||
---
|
||||
|
||||
## Account Configuration
|
||||
|
||||
Edit `src/accounts.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"enabled": true,
|
||||
"email": "email_1@outlook.com",
|
||||
"password": "password_1",
|
||||
"totp": "",
|
||||
"recoveryEmail": "your_email@domain.com",
|
||||
"proxy": {
|
||||
"proxyAxios": true,
|
||||
"url": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Features Overview
|
||||
|
||||
- Multi-account and session handling
|
||||
- Persistent browser fingerprints
|
||||
- Parallel task execution
|
||||
- Proxy and retry support
|
||||
- Human-like behavior simulation
|
||||
- Full daily set automation
|
||||
- Mobile and desktop search support
|
||||
- Vacation and risk protection
|
||||
- Webhook and Ntfy notifications
|
||||
- Docker scheduling support
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
Use at your own risk.
|
||||
Automation of Microsoft Rewards may lead to account suspension or bans.
|
||||
This software is provided for educational purposes only.
|
||||
The authors are not responsible for any actions taken by Microsoft.
|
||||
|
||||
42
compose.yaml
Normal file
42
compose.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
services:
|
||||
microsoft-rewards-script:
|
||||
build: .
|
||||
container_name: microsoft-rewards-script
|
||||
restart: unless-stopped
|
||||
|
||||
# Volume mounts: Specify a location where you want to save the files on your local machine.
|
||||
volumes:
|
||||
- ./src/accounts.json:/usr/src/microsoft-rewards-script/dist/accounts.json:ro
|
||||
- ./src/config.json:/usr/src/microsoft-rewards-script/dist/config.json:ro
|
||||
- ./sessions:/usr/src/microsoft-rewards-script/dist/browser/sessions # Optional, saves your login session
|
||||
|
||||
environment:
|
||||
TZ: "America/Toronto" # Set your timezone for proper scheduling
|
||||
NODE_ENV: "production"
|
||||
CRON_SCHEDULE: "0 7,16,20 * * *" # Customize your schedule, use crontab.guru for formatting
|
||||
RUN_ON_START: "true" # Runs the script immediately on container startup
|
||||
|
||||
# Add scheduled start-time randomization (uncomment to customize or disable, default: enabled)
|
||||
#MIN_SLEEP_MINUTES: "5"
|
||||
#MAX_SLEEP_MINUTES: "50"
|
||||
SKIP_RANDOM_SLEEP: "false"
|
||||
|
||||
# Optionally set how long to wait before killing a stuck script run (prevents blocking future runs, default: 8 hours)
|
||||
#STUCK_PROCESS_TIMEOUT_HOURS: "8"
|
||||
|
||||
# Optional resource limits for the container
|
||||
mem_limit: 4g
|
||||
cpus: 2
|
||||
|
||||
# Health check - monitors if cron daemon is running to ensure scheduled jobs can execute
|
||||
# Container marked unhealthy if cron process dies
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "pgrep cron > /dev/null || exit 1"]
|
||||
interval: 60s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
|
||||
# Security hardening
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
50
entrypoint.sh
Executable file
50
entrypoint.sh
Executable file
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Ensure Playwright uses preinstalled browsers
|
||||
export PLAYWRIGHT_BROWSERS_PATH=0
|
||||
|
||||
# 1. Timezone: default to UTC if not provided
|
||||
: "${TZ:=UTC}"
|
||||
ln -snf "/usr/share/zoneinfo/$TZ" /etc/localtime
|
||||
echo "$TZ" > /etc/timezone
|
||||
dpkg-reconfigure -f noninteractive tzdata
|
||||
|
||||
# 2. Validate CRON_SCHEDULE
|
||||
if [ -z "${CRON_SCHEDULE:-}" ]; then
|
||||
echo "ERROR: CRON_SCHEDULE environment variable is not set." >&2
|
||||
echo "Please set CRON_SCHEDULE (e.g., \"0 2 * * *\")." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Initial run without sleep if RUN_ON_START=true
|
||||
if [ "${RUN_ON_START:-false}" = "true" ]; then
|
||||
echo "[entrypoint] Starting initial run in background at $(date)"
|
||||
(
|
||||
cd /usr/src/microsoft-rewards-script || {
|
||||
echo "[entrypoint-bg] ERROR: Unable to cd to /usr/src/microsoft-rewards-script" >&2
|
||||
exit 1
|
||||
}
|
||||
# Skip random sleep for initial run, but preserve setting for cron jobs
|
||||
SKIP_RANDOM_SLEEP=true src/run_daily.sh
|
||||
echo "[entrypoint-bg] Initial run completed at $(date)"
|
||||
) &
|
||||
echo "[entrypoint] Background process started (PID: $!)"
|
||||
fi
|
||||
|
||||
# 4. Template and register cron file with explicit timezone export
|
||||
if [ ! -f /etc/cron.d/microsoft-rewards-cron.template ]; then
|
||||
echo "ERROR: Cron template /etc/cron.d/microsoft-rewards-cron.template not found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Export TZ for envsubst to use
|
||||
export TZ
|
||||
envsubst < /etc/cron.d/microsoft-rewards-cron.template > /etc/cron.d/microsoft-rewards-cron
|
||||
chmod 0644 /etc/cron.d/microsoft-rewards-cron
|
||||
crontab /etc/cron.d/microsoft-rewards-cron
|
||||
|
||||
echo "[entrypoint] Cron configured with schedule: $CRON_SCHEDULE and timezone: $TZ; starting cron at $(date)"
|
||||
|
||||
# 5. Start cron in foreground (PID 1)
|
||||
exec cron -f
|
||||
61
flake.lock
generated
Normal file
61
flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1749727998,
|
||||
"narHash": "sha256-mHv/yeUbmL91/TvV95p+mBVahm9mdQMJoqaTVTALaFw=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "fd487183437963a59ba763c0cc4f27e3447dd6dd",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-25.05",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
40
flake.nix
Normal file
40
flake.nix
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
|
||||
flake-utils = {
|
||||
url = "github:numtide/flake-utils";
|
||||
};
|
||||
};
|
||||
|
||||
outputs =
|
||||
{ nixpkgs, flake-utils, ... }:
|
||||
flake-utils.lib.eachDefaultSystem (
|
||||
system:
|
||||
let
|
||||
pkgs = import nixpkgs {
|
||||
inherit system;
|
||||
};
|
||||
in
|
||||
{
|
||||
devShell = pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
nodejs
|
||||
playwright-driver.browsers
|
||||
typescript
|
||||
playwright-test
|
||||
|
||||
# fixes "waiting until load" issue compared to
|
||||
# setting headless in config.json
|
||||
xvfb-run
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers}
|
||||
export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true
|
||||
npm i
|
||||
npm run build
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
3174
package-lock.json
generated
Normal file
3174
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
62
package.json
62
package.json
@@ -1,16 +1,32 @@
|
||||
{
|
||||
"name": "microsoft-rewards-script",
|
||||
"version": "1.2.4",
|
||||
"version": "2.4.1",
|
||||
"description": "Automatically do tasks for Microsoft Rewards but in TS!",
|
||||
"private": true,
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/TheNetsky/Microsoft-Rewards-Script.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/TheNetsky/Microsoft-Rewards-Script/issues"
|
||||
},
|
||||
"homepage": "https://github.com/TheNetsky/Microsoft-Rewards-Script#readme",
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"pre-build": "npm i && npm run clean && node -e \"process.exit(process.env.SKIP_PLAYWRIGHT_INSTALL?0:1)\" || npx playwright install chromium",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc",
|
||||
"start": "node ./dist/index.js",
|
||||
"ts-start": "ts-node ./src/index.ts",
|
||||
"dev": "ts-node ./src/index.ts -dev"
|
||||
"start": "node --enable-source-maps ./dist/index.js",
|
||||
"ts-start": "node --loader ts-node/esm ./src/index.ts",
|
||||
"dev": "ts-node ./src/index.ts -dev",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"prepare": "npm run build",
|
||||
"kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\"",
|
||||
"create-docker": "docker build -t microsoft-rewards-script-docker ."
|
||||
},
|
||||
"keywords": [
|
||||
"Bing Rewards",
|
||||
@@ -18,24 +34,34 @@
|
||||
"Bot",
|
||||
"Script",
|
||||
"TypeScript",
|
||||
"Puppeteer",
|
||||
"Playwright",
|
||||
"Cheerio"
|
||||
],
|
||||
"author": "Netsky",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||
"typescript": "^5.2.2"
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/node": "^20.14.11",
|
||||
"@typescript-eslint/eslint-plugin": "^7.17.0",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-modules-newline": "^0.0.6",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.5.1",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-plugin-modules-newline": "^0.0.6",
|
||||
"fingerprint-generator": "^2.1.42",
|
||||
"fingerprint-injector": "^2.1.42",
|
||||
"puppeteer": "^21.4.1",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"ts-node": "^10.9.1"
|
||||
"axios": "^1.13.2",
|
||||
"chalk": "^4.1.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"cron-parser": "^5.4.0",
|
||||
"fingerprint-generator": "^2.1.76",
|
||||
"fingerprint-injector": "^2.1.76",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"luxon": "^3.5.0",
|
||||
"ms": "^2.1.3",
|
||||
"playwright": "1.52.0",
|
||||
"rebrowser-playwright": "1.52.0",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"ts-node": "^10.9.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
run.sh
Normal file
3
run.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
nix develop --command bash -c "xvfb-run npm run start"
|
||||
@@ -1,22 +1,32 @@
|
||||
[
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"email": "email_1",
|
||||
"password": "password_1",
|
||||
"proxy": {
|
||||
"url": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
"enabled": true,
|
||||
"email": "email_1@outlook.com",
|
||||
"password": "password_1",
|
||||
"totp": "",
|
||||
"recoveryEmail": "your_email@domain.com",
|
||||
"proxy": {
|
||||
"proxyAxios": true,
|
||||
"url": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
},
|
||||
{
|
||||
"email": "email_2",
|
||||
"password": "password_2",
|
||||
"proxy": {
|
||||
"url": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
"enabled": false,
|
||||
"email": "email_2@outlook.com",
|
||||
"password": "password_2",
|
||||
"totp": "",
|
||||
"recoveryEmail": "your_email@domain.com",
|
||||
"proxy": {
|
||||
"proxyAxios": true,
|
||||
"url": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": ""
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -1,18 +1,22 @@
|
||||
import puppeteer from 'puppeteer-extra'
|
||||
import { FingerprintInjector } from 'fingerprint-injector'
|
||||
import playwright, { BrowserContext } from 'rebrowser-playwright'
|
||||
|
||||
import { newInjectedContext } from 'fingerprint-injector'
|
||||
import { FingerprintGenerator } from 'fingerprint-generator'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import { loadSessionData, saveFingerprintData } from '../util/Load'
|
||||
import { updateFingerprintUserAgent } from '../util/UserAgent'
|
||||
|
||||
import { AccountProxy } from '../interface/Account'
|
||||
|
||||
/* Test Stuff
|
||||
https://abrahamjuliot.github.io/creepjs/
|
||||
https://botcheck.luminati.io/
|
||||
http://f.vision/
|
||||
https://fv.pro/
|
||||
https://pixelscan.net/
|
||||
https://www.browserscan.net/
|
||||
*/
|
||||
|
||||
|
||||
class Browser {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
@@ -20,38 +24,117 @@ class Browser {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
async createBrowser(email: string, proxy: AccountProxy, isMobile: boolean) {
|
||||
// const userAgent = await getUserAgent(isMobile)
|
||||
async createBrowser(proxy: AccountProxy, email: string): Promise<BrowserContext> {
|
||||
let browser: playwright.Browser
|
||||
try {
|
||||
// FORCE_HEADLESS env takes precedence (used in Docker with headless shell only)
|
||||
const envForceHeadless = process.env.FORCE_HEADLESS === '1'
|
||||
// Support legacy config.headless OR nested config.browser.headless
|
||||
const legacyHeadless = (this.bot.config as { headless?: boolean }).headless
|
||||
const nestedHeadless = (this.bot.config.browser as { headless?: boolean } | undefined)?.headless
|
||||
const headlessValue = envForceHeadless ? true : (legacyHeadless ?? nestedHeadless ?? false)
|
||||
const headless: boolean = Boolean(headlessValue)
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: this.bot.config.headless,
|
||||
userDataDir: await this.bot.browser.func.loadSesion(email),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--mute-audio',
|
||||
'--disable-setuid-sandbox',
|
||||
proxy.url ? `--proxy-server=${proxy.url}:${proxy.port}` : ''
|
||||
]
|
||||
})
|
||||
|
||||
const { fingerprint, headers } = new FingerprintGenerator().getFingerprint({
|
||||
devices: isMobile ? ['mobile'] : ['desktop'],
|
||||
operatingSystems: isMobile ? ['android'] : ['windows'],
|
||||
browsers: ['edge'],
|
||||
browserListQuery: 'last 2 Edge versions'
|
||||
})
|
||||
|
||||
// Modify the newPage function to attach the fingerprint
|
||||
const originalNewPage = browser.newPage
|
||||
browser.newPage = async function () {
|
||||
const page = await originalNewPage.apply(browser)
|
||||
await new FingerprintInjector().attachFingerprintToPuppeteer(page, { fingerprint, headers })
|
||||
return page
|
||||
const engineName = 'chromium' // current hard-coded engine
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', `Launching ${engineName} (headless=${headless})`) // explicit engine log
|
||||
browser = await playwright.chromium.launch({
|
||||
// Optional: uncomment to use Edge instead of Chromium
|
||||
// channel: 'msedge',
|
||||
headless,
|
||||
...(proxy.url && { proxy: { username: proxy.username, password: proxy.password, server: `${proxy.url}:${proxy.port}` } }),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--mute-audio',
|
||||
'--disable-setuid-sandbox',
|
||||
'--ignore-certificate-errors',
|
||||
'--ignore-certificate-errors-spki-list',
|
||||
'--ignore-ssl-errors'
|
||||
]
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
const msg = (e instanceof Error ? e.message : String(e))
|
||||
// Common missing browser executable guidance
|
||||
if (/Executable doesn't exist/i.test(msg)) {
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', 'Chromium not installed for Playwright. Run "npm run pre-build" to install all dependencies', 'error')
|
||||
} else {
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', 'Failed to launch browser: ' + msg, 'error')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
return browser
|
||||
// Resolve saveFingerprint from legacy root or new fingerprinting.saveFingerprint
|
||||
const legacyFp = (this.bot.config as { saveFingerprint?: { mobile: boolean; desktop: boolean } }).saveFingerprint
|
||||
const nestedFp = (this.bot.config.fingerprinting as { saveFingerprint?: { mobile: boolean; desktop: boolean } } | undefined)?.saveFingerprint
|
||||
const saveFingerprint = legacyFp || nestedFp || { mobile: false, desktop: false }
|
||||
|
||||
const sessionData = await loadSessionData(this.bot.config.sessionPath, email, this.bot.isMobile, saveFingerprint)
|
||||
|
||||
const fingerprint = sessionData.fingerprint ? sessionData.fingerprint : await this.generateFingerprint()
|
||||
|
||||
const context = await newInjectedContext(browser as unknown as import('playwright').Browser, { fingerprint: fingerprint })
|
||||
|
||||
// Set timeout to preferred amount (supports legacy globalTimeout or browser.globalTimeout)
|
||||
const legacyTimeout = (this.bot.config as { globalTimeout?: number | string }).globalTimeout
|
||||
const nestedTimeout = (this.bot.config.browser as { globalTimeout?: number | string } | undefined)?.globalTimeout
|
||||
const globalTimeout = legacyTimeout ?? nestedTimeout ?? 30000
|
||||
context.setDefaultTimeout(this.bot.utils.stringToMs(globalTimeout))
|
||||
|
||||
// Normalize viewport and page rendering so content fits typical screens
|
||||
try {
|
||||
const desktopViewport = { width: 1280, height: 800 }
|
||||
const mobileViewport = { width: 390, height: 844 }
|
||||
|
||||
context.on('page', async (page) => {
|
||||
try {
|
||||
// Set a reasonable viewport size depending on device type
|
||||
if (this.bot.isMobile) {
|
||||
await page.setViewportSize(mobileViewport)
|
||||
} else {
|
||||
await page.setViewportSize(desktopViewport)
|
||||
}
|
||||
|
||||
// Inject a tiny CSS to avoid gigantic scaling on some environments
|
||||
await page.addInitScript(() => {
|
||||
try {
|
||||
const style = document.createElement('style')
|
||||
style.id = '__mrs_fit_style'
|
||||
style.textContent = `
|
||||
html, body { overscroll-behavior: contain; }
|
||||
/* Mild downscale to keep content within window on very large DPI */
|
||||
@media (min-width: 1000px) {
|
||||
html { zoom: 0.9 !important; }
|
||||
}
|
||||
`
|
||||
document.documentElement.appendChild(style)
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
})
|
||||
} catch { /* ignore */ }
|
||||
|
||||
await context.addCookies(sessionData.cookies)
|
||||
|
||||
// Persist fingerprint when feature is configured
|
||||
if (saveFingerprint.mobile || saveFingerprint.desktop) {
|
||||
await saveFingerprintData(this.bot.config.sessionPath, email, this.bot.isMobile, fingerprint)
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'BROWSER', `Created browser with User-Agent: "${fingerprint.fingerprint.navigator.userAgent}"`)
|
||||
|
||||
return context as BrowserContext
|
||||
}
|
||||
|
||||
async generateFingerprint() {
|
||||
const fingerPrintData = new FingerprintGenerator().getFingerprint({
|
||||
devices: this.bot.isMobile ? ['mobile'] : ['desktop'],
|
||||
operatingSystems: this.bot.isMobile ? ['android'] : ['windows'],
|
||||
browsers: [{ name: 'edge' }]
|
||||
})
|
||||
|
||||
const updatedFingerPrintData = await updateFingerprintUserAgent(fingerPrintData, this.bot.isMobile)
|
||||
|
||||
return updatedFingerPrintData
|
||||
}
|
||||
}
|
||||
|
||||
export default Browser
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import { BrowserContext, Page } from 'rebrowser-playwright'
|
||||
import { CheerioAPI, load } from 'cheerio'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import { saveSessionData } from '../util/Load'
|
||||
import { TIMEOUTS, RETRY_LIMITS, SELECTORS, URLS } from '../constants'
|
||||
|
||||
import { Counters, DashboardData, MorePromotion, PromotionalItem } from './../interface/DashboardData'
|
||||
import { QuizData } from './../interface/QuizData'
|
||||
import { Counters, DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData'
|
||||
import { QuizData } from '../interface/QuizData'
|
||||
import { AppUserData } from '../interface/AppUserData'
|
||||
import { EarnablePoints } from '../interface/Points'
|
||||
|
||||
|
||||
export default class BrowserFunc {
|
||||
@@ -16,210 +19,492 @@ export default class BrowserFunc {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
async goHome(page: Page): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Navigate the provided page to rewards homepage
|
||||
* @param {Page} page Playwright page
|
||||
*/
|
||||
async goHome(page: Page) {
|
||||
const navigateHome = async () => {
|
||||
try {
|
||||
await page.goto(this.bot.config.baseURL, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000
|
||||
})
|
||||
} catch (e: any) {
|
||||
if (typeof e?.message === 'string' && e.message.includes('ERR_ABORTED')) {
|
||||
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Navigation aborted, retrying...', 'warn')
|
||||
await this.bot.utils.wait(1500)
|
||||
await page.goto(this.bot.config.baseURL, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000
|
||||
})
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const dashboardURL = new URL(this.bot.config.baseURL)
|
||||
|
||||
await page.goto(this.bot.config.baseURL)
|
||||
if (new URL(page.url()).hostname !== dashboardURL.hostname) {
|
||||
await navigateHome()
|
||||
}
|
||||
|
||||
const maxIterations = 5 // Maximum iterations set to 5
|
||||
let success = false
|
||||
|
||||
for (let iteration = 1; iteration <= maxIterations; iteration++) {
|
||||
await this.bot.utils.wait(3000)
|
||||
await this.bot.browser.utils.tryDismissCookieBanner(page)
|
||||
|
||||
// Check if account is suspended
|
||||
const isSuspended = await page.waitForSelector('#suspendedAccountHeader', { visible: true, timeout: 3000 }).then(() => true).catch(() => false)
|
||||
if (isSuspended) {
|
||||
this.bot.log('GO-HOME', 'This account is suspended!', 'error')
|
||||
throw new Error('Account has been suspended!')
|
||||
}
|
||||
for (let iteration = 1; iteration <= RETRY_LIMITS.GO_HOME_MAX; iteration++) {
|
||||
await this.bot.utils.wait(TIMEOUTS.LONG)
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
|
||||
try {
|
||||
// If activities are found, exit the loop
|
||||
await page.waitForSelector('#more-activities', { timeout: 1000 })
|
||||
await page.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: 1000 })
|
||||
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Visited homepage successfully')
|
||||
success = true
|
||||
break
|
||||
} catch {
|
||||
const suspendedByHeader = await page
|
||||
.waitForSelector(SELECTORS.SUSPENDED_ACCOUNT, { state: 'visible', timeout: 500 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
} catch (error) {
|
||||
// Continue if element is not found
|
||||
if (suspendedByHeader) {
|
||||
this.bot.log(
|
||||
this.bot.isMobile,
|
||||
'GO-HOME',
|
||||
`Account suspension detected by header selector (iteration ${iteration})`,
|
||||
'error'
|
||||
)
|
||||
throw new Error('Account has been suspended!')
|
||||
}
|
||||
|
||||
try {
|
||||
const mainContent =
|
||||
(await page
|
||||
.locator('#contentContainer, #main, .main-content')
|
||||
.first()
|
||||
.textContent({ timeout: 500 })
|
||||
.catch(() => '')) || ''
|
||||
|
||||
const suspensionPatterns = [
|
||||
/account\s+has\s+been\s+suspended/i,
|
||||
/suspended\s+due\s+to\s+unusual\s+activity/i,
|
||||
/your\s+account\s+is\s+temporarily\s+suspended/i
|
||||
]
|
||||
|
||||
const isSuspended = suspensionPatterns.some((p) => p.test(mainContent))
|
||||
if (isSuspended) {
|
||||
this.bot.log(
|
||||
this.bot.isMobile,
|
||||
'GO-HOME',
|
||||
`Account suspension detected by content text (iteration ${iteration})`,
|
||||
'error'
|
||||
)
|
||||
throw new Error('Account has been suspended!')
|
||||
}
|
||||
} catch (e) {
|
||||
this.bot.log(
|
||||
this.bot.isMobile,
|
||||
'GO-HOME',
|
||||
`Suspension text check skipped: ${e instanceof Error ? e.message : String(e)}`,
|
||||
'warn'
|
||||
)
|
||||
}
|
||||
|
||||
const currentURL = new URL(page.url())
|
||||
if (currentURL.hostname !== dashboardURL.hostname) {
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
|
||||
try {
|
||||
await navigateHome()
|
||||
} catch (e: any) {
|
||||
if (typeof e?.message === 'string' && e.message.includes('ERR_ABORTED')) {
|
||||
this.bot.log(this.bot.isMobile, 'GO-HOME', 'Navigation aborted again; continuing...', 'warn')
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.bot.log(
|
||||
this.bot.isMobile,
|
||||
'GO-HOME',
|
||||
`Activities not found yet (iteration ${iteration}/${RETRY_LIMITS.GO_HOME_MAX}), retrying...`,
|
||||
'warn'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const currentURL = new URL(page.url())
|
||||
|
||||
if (currentURL.hostname !== dashboardURL.hostname) {
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
|
||||
await this.bot.utils.wait(2000)
|
||||
await page.goto(this.bot.config.baseURL)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(5000)
|
||||
this.bot.log('GO-HOME', 'Visited homepage successfully')
|
||||
const backoff = Math.min(TIMEOUTS.VERY_LONG, 1000 + iteration * 500)
|
||||
await this.bot.utils.wait(backoff)
|
||||
}
|
||||
|
||||
if (!success) {
|
||||
throw new Error('Failed to reach homepage or find activities within retry limit')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('An error occurred:', error)
|
||||
return false
|
||||
throw this.bot.log(
|
||||
this.bot.isMobile,
|
||||
'GO-HOME',
|
||||
'An error occurred:' + (error instanceof Error ? ` ${error.message}` : ` ${String(error)}`),
|
||||
'error'
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async getDashboardData(page: Page): Promise<DashboardData> {
|
||||
|
||||
/**
|
||||
* Fetch user dashboard data
|
||||
* @returns {DashboardData} Object of user bing rewards dashboard data
|
||||
*/
|
||||
async getDashboardData(page?: Page): Promise<DashboardData> {
|
||||
const target = page ?? this.bot.homePage
|
||||
const dashboardURL = new URL(this.bot.config.baseURL)
|
||||
const currentURL = new URL(page.url())
|
||||
const currentURL = new URL(target.url())
|
||||
|
||||
// Should never happen since tasks are opened in a new tab!
|
||||
if (currentURL.hostname !== dashboardURL.hostname) {
|
||||
this.bot.log('DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page')
|
||||
await this.goHome(page)
|
||||
}
|
||||
|
||||
// Reload the page to get new data
|
||||
await page.reload({ waitUntil: 'networkidle2' })
|
||||
|
||||
const scriptContent = await page.evaluate(() => {
|
||||
const scripts = Array.from(document.querySelectorAll('script'))
|
||||
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
|
||||
|
||||
if (targetScript) {
|
||||
return targetScript.innerText
|
||||
} else {
|
||||
throw this.bot.log('GET-DASHBOARD-DATA', 'Script containing dashboard data not found', 'error')
|
||||
}
|
||||
})
|
||||
|
||||
// Extract the dashboard object from the script content
|
||||
const dashboardData = await page.evaluate(scriptContent => {
|
||||
// Extract the dashboard object using regex
|
||||
const regex = /var dashboard = (\{.*?\});/s
|
||||
const match = regex.exec(scriptContent)
|
||||
|
||||
if (match && match[1]) {
|
||||
return JSON.parse(match[1])
|
||||
} else {
|
||||
throw this.bot.log('GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error')
|
||||
}
|
||||
}, scriptContent)
|
||||
|
||||
return dashboardData
|
||||
}
|
||||
|
||||
async getQuizData(page: Page): Promise<QuizData> {
|
||||
try {
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
|
||||
const scriptContent = $('script').filter((index, element) => {
|
||||
return $(element).text().includes('_w.rewardsQuizRenderInfo')
|
||||
}).text()
|
||||
|
||||
if (scriptContent) {
|
||||
const regex = /_w\.rewardsQuizRenderInfo\s*=\s*({.*?});/s
|
||||
const match = regex.exec(scriptContent)
|
||||
|
||||
if (match && match[1]) {
|
||||
const quizData = JSON.parse(match[1])
|
||||
return quizData
|
||||
} else {
|
||||
throw this.bot.log('GET-QUIZ-DATA', 'Quiz data not found within script', 'error')
|
||||
}
|
||||
} else {
|
||||
throw this.bot.log('GET-QUIZ-DATA', 'Script containing quiz data not found', 'error')
|
||||
// Should never happen since tasks are opened in a new tab!
|
||||
if (currentURL.hostname !== dashboardURL.hostname) {
|
||||
this.bot.log(this.bot.isMobile, 'DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page')
|
||||
await this.goHome(target)
|
||||
}
|
||||
let lastError: unknown = null
|
||||
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||
try {
|
||||
// Reload the page to get new data
|
||||
await target.reload({ waitUntil: 'domcontentloaded' })
|
||||
lastError = null
|
||||
break
|
||||
} catch (re) {
|
||||
lastError = re
|
||||
const msg = (re instanceof Error ? re.message : String(re))
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Reload failed attempt ${attempt}: ${msg}`, 'warn')
|
||||
// If page/context closed => bail early after first retry
|
||||
if (msg.includes('has been closed')) {
|
||||
if (attempt === 1) {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Page appears closed; trying one navigation fallback', 'warn')
|
||||
try {
|
||||
await this.goHome(target)
|
||||
} catch {/* ignore */ }
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
if (attempt === 2 && lastError) throw lastError
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait a bit longer for scripts to load, especially on mobile
|
||||
await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM)
|
||||
|
||||
// Wait for the more-activities element to ensure page is fully loaded
|
||||
await target.waitForSelector(SELECTORS.MORE_ACTIVITIES, { timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(() => {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Activities element not found, continuing anyway', 'warn')
|
||||
})
|
||||
|
||||
let scriptContent = await target.evaluate(() => {
|
||||
const scripts = Array.from(document.querySelectorAll('script'))
|
||||
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
|
||||
|
||||
return targetScript?.innerText ? targetScript.innerText : null
|
||||
})
|
||||
|
||||
if (!scriptContent) {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard script not found on first try, attempting recovery', 'warn')
|
||||
|
||||
// Force a navigation retry once before failing hard
|
||||
try {
|
||||
await this.goHome(target)
|
||||
await target.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.VERY_LONG }).catch((e) => {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Wait for load state failed: ${e}`, 'warn')
|
||||
})
|
||||
await this.bot.utils.wait(this.bot.isMobile ? TIMEOUTS.LONG : TIMEOUTS.MEDIUM)
|
||||
} catch (e) {
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Recovery navigation failed: ${e}`, 'warn')
|
||||
}
|
||||
|
||||
const retryContent = await target.evaluate(() => {
|
||||
const scripts = Array.from(document.querySelectorAll('script'))
|
||||
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
|
||||
return targetScript?.innerText ? targetScript.innerText : null
|
||||
}).catch(() => null)
|
||||
|
||||
if (!retryContent) {
|
||||
// Log additional debug info
|
||||
const scriptsDebug = await target.evaluate(() => {
|
||||
const scripts = Array.from(document.querySelectorAll('script'))
|
||||
return scripts.map(s => s.innerText.substring(0, 100)).join(' | ')
|
||||
}).catch(() => 'Unable to get script debug info')
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Available scripts preview: ${scriptsDebug}`, 'warn')
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error')
|
||||
}
|
||||
scriptContent = retryContent
|
||||
}
|
||||
|
||||
// Extract the dashboard object from the script content
|
||||
const dashboardData = await target.evaluate((scriptContent: string) => {
|
||||
// Try multiple regex patterns for better compatibility
|
||||
const patterns = [
|
||||
/var dashboard = (\{.*?\});/s, // Original pattern
|
||||
/var dashboard=(\{.*?\});/s, // No spaces
|
||||
/var\s+dashboard\s*=\s*(\{.*?\});/s, // Flexible whitespace
|
||||
/dashboard\s*=\s*(\{[\s\S]*?\});/ // More permissive
|
||||
]
|
||||
|
||||
for (const regex of patterns) {
|
||||
const match = regex.exec(scriptContent)
|
||||
if (match && match[1]) {
|
||||
try {
|
||||
return JSON.parse(match[1])
|
||||
} catch (e) {
|
||||
// Try next pattern if JSON parsing fails
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
|
||||
}, scriptContent)
|
||||
|
||||
if (!dashboardData) {
|
||||
// Log a snippet of the script content for debugging
|
||||
const scriptPreview = scriptContent.substring(0, 200)
|
||||
this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Script preview: ${scriptPreview}`, 'warn')
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', 'Unable to parse dashboard script', 'error')
|
||||
}
|
||||
|
||||
return dashboardData
|
||||
|
||||
} catch (error) {
|
||||
throw this.bot.log('GET-QUIZ-DATA', 'An error occurred:' + error, 'error')
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Error fetching dashboard data: ${error}`, 'error')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async getSearchPoints(page: Page): Promise<Counters> {
|
||||
const dashboardData = await this.getDashboardData(page) // Always fetch newest data
|
||||
/**
|
||||
* Get search point counters
|
||||
* @returns {Counters} Object of search counter data
|
||||
*/
|
||||
async getSearchPoints(): Promise<Counters> {
|
||||
const dashboardData = await this.getDashboardData() // Always fetch newest data
|
||||
|
||||
return dashboardData.userStatus.counters
|
||||
}
|
||||
|
||||
async getEarnablePoints(data: DashboardData, page: null | Page = null): Promise<number> {
|
||||
/**
|
||||
* Get total earnable points with web browser
|
||||
* @returns {number} Total earnable points
|
||||
*/
|
||||
async getBrowserEarnablePoints(): Promise<EarnablePoints> {
|
||||
try {
|
||||
// Fetch new data if page is provided
|
||||
if (page) {
|
||||
data = await this.getDashboardData(page)
|
||||
}
|
||||
let desktopSearchPoints = 0
|
||||
let mobileSearchPoints = 0
|
||||
let dailySetPoints = 0
|
||||
let morePromotionsPoints = 0
|
||||
|
||||
// These only include the points from tasks that the script can complete!
|
||||
let totalEarnablePoints = 0
|
||||
const data = await this.getDashboardData()
|
||||
|
||||
// Desktop Search Points
|
||||
data.userStatus.counters.pcSearch.forEach(x => totalEarnablePoints += (x.pointProgressMax - x.pointProgress))
|
||||
if (data.userStatus.counters.pcSearch?.length) {
|
||||
data.userStatus.counters.pcSearch.forEach(x => desktopSearchPoints += (x.pointProgressMax - x.pointProgress))
|
||||
}
|
||||
|
||||
// Mobile Search Points
|
||||
if (data.userStatus.counters.mobileSearch?.length) {
|
||||
data.userStatus.counters.mobileSearch.forEach(x => totalEarnablePoints += (x.pointProgressMax - x.pointProgress))
|
||||
data.userStatus.counters.mobileSearch.forEach(x => mobileSearchPoints += (x.pointProgressMax - x.pointProgress))
|
||||
}
|
||||
|
||||
// Daily Set
|
||||
data.dailySetPromotions[this.bot.utils.getFormattedDate()]?.forEach(x => totalEarnablePoints += (x.pointProgressMax - x.pointProgress))
|
||||
data.dailySetPromotions[this.bot.utils.getFormattedDate()]?.forEach(x => dailySetPoints += (x.pointProgressMax - x.pointProgress))
|
||||
|
||||
// More Promotions
|
||||
data.morePromotions.forEach(x => {
|
||||
// Only count points from supported activities
|
||||
if (['quiz', 'urlreward'].includes(x.activityType)) {
|
||||
totalEarnablePoints += (x.pointProgressMax - x.pointProgress)
|
||||
}
|
||||
})
|
||||
if (data.morePromotions?.length) {
|
||||
data.morePromotions.forEach(x => {
|
||||
// Only count points from supported activities
|
||||
if (['quiz', 'urlreward'].includes(x.promotionType) && x.exclusiveLockedFeatureStatus !== 'locked') {
|
||||
morePromotionsPoints += (x.pointProgressMax - x.pointProgress)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return totalEarnablePoints
|
||||
const totalEarnablePoints = desktopSearchPoints + mobileSearchPoints + dailySetPoints + morePromotionsPoints
|
||||
|
||||
return {
|
||||
dailySetPoints,
|
||||
morePromotionsPoints,
|
||||
desktopSearchPoints,
|
||||
mobileSearchPoints,
|
||||
totalEarnablePoints
|
||||
}
|
||||
} catch (error) {
|
||||
throw this.bot.log('GET-EARNABLE-POINTS', 'An error occurred:' + error, 'error')
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-BROWSER-EARNABLE-POINTS', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentPoints(data: DashboardData, page: null | Page = null): Promise<number> {
|
||||
/**
|
||||
* Get total earnable points with mobile app
|
||||
* @returns {number} Total earnable points
|
||||
*/
|
||||
async getAppEarnablePoints(accessToken: string) {
|
||||
try {
|
||||
// Fetch new data if page is provided
|
||||
if (page) {
|
||||
data = await this.getDashboardData(page)
|
||||
const points = {
|
||||
readToEarn: 0,
|
||||
checkIn: 0,
|
||||
totalEarnablePoints: 0
|
||||
}
|
||||
|
||||
const eligibleOffers = [
|
||||
'ENUS_readarticle3_30points',
|
||||
'Gamification_Sapphire_DailyCheckIn'
|
||||
]
|
||||
|
||||
const data = await this.getDashboardData()
|
||||
// Guard against missing profile/attributes and undefined settings
|
||||
let geoLocale = data?.userProfile?.attributes?.country || 'US'
|
||||
const useGeo = !!(this.bot?.config?.searchSettings?.useGeoLocaleQueries)
|
||||
geoLocale = (useGeo && typeof geoLocale === 'string' && geoLocale.length === 2)
|
||||
? geoLocale.toLowerCase()
|
||||
: 'us'
|
||||
|
||||
const userDataRequest: AxiosRequestConfig = {
|
||||
url: URLS.APP_USER_DATA,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'X-Rewards-Country': geoLocale,
|
||||
'X-Rewards-Language': 'en'
|
||||
}
|
||||
}
|
||||
|
||||
const userDataResponse: AppUserData = (await this.bot.axios.request(userDataRequest)).data
|
||||
const userData = userDataResponse.response
|
||||
const eligibleActivities = userData.promotions.filter((x) => eligibleOffers.includes(x.attributes.offerid ?? ''))
|
||||
|
||||
for (const item of eligibleActivities) {
|
||||
if (item.attributes.type === 'msnreadearn') {
|
||||
points.readToEarn = parseInt(item.attributes.pointmax ?? '') - parseInt(item.attributes.pointprogress ?? '')
|
||||
break
|
||||
} else if (item.attributes.type === 'checkin') {
|
||||
const checkInDay = parseInt(item.attributes.progress ?? '') % 7
|
||||
|
||||
if (checkInDay < 6 && (new Date()).getDate() != (new Date(item.attributes.last_updated ?? '')).getDate()) {
|
||||
points.checkIn = parseInt(item.attributes['day_' + (checkInDay + 1) + '_points'] ?? '')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
points.totalEarnablePoints = points.readToEarn + points.checkIn
|
||||
|
||||
return points
|
||||
} catch (error) {
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-APP-EARNABLE-POINTS', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current point amount
|
||||
* @returns {number} Current total point amount
|
||||
*/
|
||||
async getCurrentPoints(): Promise<number> {
|
||||
try {
|
||||
const data = await this.getDashboardData()
|
||||
|
||||
return data.userStatus.availablePoints
|
||||
} catch (error) {
|
||||
throw this.bot.log('GET-CURRENT-POINTS', 'An error occurred:' + error, 'error')
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-CURRENT-POINTS', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async loadSesion(email: string): Promise<string> {
|
||||
const sessionDir = path.join(__dirname, this.bot.config.sessionPath, email)
|
||||
|
||||
/**
|
||||
* Parse quiz data from provided page
|
||||
* @param {Page} page Playwright page
|
||||
* @returns {QuizData} Quiz data object
|
||||
*/
|
||||
async getQuizData(page: Page): Promise<QuizData> {
|
||||
try {
|
||||
// Create session dir
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
await fs.promises.mkdir(sessionDir, { recursive: true })
|
||||
// Wait for page to be fully loaded
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM)
|
||||
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
|
||||
// Try multiple possible variable names
|
||||
const possibleVariables = [
|
||||
'_w.rewardsQuizRenderInfo',
|
||||
'rewardsQuizRenderInfo',
|
||||
'_w.quizRenderInfo',
|
||||
'quizRenderInfo'
|
||||
]
|
||||
|
||||
let scriptContent = ''
|
||||
let foundVariable = ''
|
||||
|
||||
for (const varName of possibleVariables) {
|
||||
scriptContent = $('script')
|
||||
.toArray()
|
||||
.map(el => $(el).text())
|
||||
.find(t => t.includes(varName)) || ''
|
||||
|
||||
if (scriptContent) {
|
||||
foundVariable = varName
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (scriptContent && foundVariable) {
|
||||
// Escape dots in variable name for regex
|
||||
const escapedVar = foundVariable.replace(/\./g, '\\.')
|
||||
const regex = new RegExp(`${escapedVar}\\s*=\\s*({.*?});`, 's')
|
||||
const match = regex.exec(scriptContent)
|
||||
|
||||
if (match && match[1]) {
|
||||
const quizData = JSON.parse(match[1])
|
||||
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found quiz data using variable: ${foundVariable}`, 'log')
|
||||
return quizData
|
||||
} else {
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Variable ${foundVariable} found but could not extract JSON data`, 'error')
|
||||
}
|
||||
} else {
|
||||
// Log available scripts for debugging
|
||||
const allScripts = $('script')
|
||||
.toArray()
|
||||
.map(el => $(el).text())
|
||||
.filter(t => t.length > 0)
|
||||
.map(t => t.substring(0, 100))
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Script not found. Tried variables: ${possibleVariables.join(', ')}`, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Found ${allScripts.length} scripts on page`, 'warn')
|
||||
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'Script containing quiz data not found', 'error')
|
||||
}
|
||||
|
||||
return sessionDir
|
||||
} catch (error) {
|
||||
throw new Error(error as string)
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', 'An error occurred: ' + error, 'error')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async waitForQuizRefresh(page: Page): Promise<boolean> {
|
||||
try {
|
||||
await page.waitForSelector('#rqHeaderCredits', { visible: true, timeout: 10_000 })
|
||||
await this.bot.utils.wait(2000)
|
||||
await page.waitForSelector(SELECTORS.QUIZ_CREDITS, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
this.bot.log('QUIZ-REFRESH', 'An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ-REFRESH', 'An error occurred:' + error, 'error')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async checkQuizCompleted(page: Page): Promise<boolean> {
|
||||
try {
|
||||
await page.waitForSelector('#quizCompleteContainer', { visible: true, timeout: 2000 })
|
||||
await this.bot.utils.wait(2000)
|
||||
await page.waitForSelector(SELECTORS.QUIZ_COMPLETE, { state: 'visible', timeout: TIMEOUTS.MEDIUM_LONG })
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
@@ -227,7 +512,7 @@ export default class BrowserFunc {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshCheerio(page: Page): Promise<CheerioAPI> {
|
||||
async loadInCheerio(page: Page): Promise<CheerioAPI> {
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
|
||||
@@ -240,15 +525,32 @@ export default class BrowserFunc {
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
|
||||
const element = $('.offer-cta').toArray().find(x => x.attribs.href?.includes(activity.offerId))
|
||||
const element = $('.offer-cta').toArray().find((x: unknown) => {
|
||||
const el = x as { attribs?: { href?: string } }
|
||||
return !!el.attribs?.href?.includes(activity.offerId)
|
||||
})
|
||||
if (element) {
|
||||
selector = `a[href*="${element.attribs.href}"]`
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.log('GET-PUNCHCARD-ACTIVITY', 'An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'GET-PUNCHCARD-ACTIVITY', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
|
||||
return selector
|
||||
}
|
||||
|
||||
async closeBrowser(browser: BrowserContext, email: string) {
|
||||
try {
|
||||
// Save cookies
|
||||
await saveSessionData(this.bot.config.sessionPath, browser, email, this.bot.isMobile)
|
||||
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
|
||||
|
||||
// Close browser
|
||||
await browser.close()
|
||||
this.bot.log(this.bot.isMobile, 'CLOSE-BROWSER', 'Browser closed cleanly!')
|
||||
} catch (error) {
|
||||
throw this.bot.log(this.bot.isMobile, 'CLOSE-BROWSER', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,90 +1,219 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
import { load } from 'cheerio'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
|
||||
type DismissButton = { selector: string; label: string; isXPath?: boolean }
|
||||
|
||||
export default class BrowserUtil {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
private static readonly DISMISS_BUTTONS: readonly DismissButton[] = [
|
||||
{ selector: '#acceptButton', label: 'AcceptButton' },
|
||||
{ selector: '.optanon-allow-all, .optanon-alert-box-button', label: 'OneTrust Accept' },
|
||||
{ selector: '.ext-secondary.ext-button', label: 'Skip For Now' },
|
||||
{ selector: '#iLandingViewAction', label: 'Landing Continue' },
|
||||
{ selector: '#iShowSkip', label: 'Show Skip' },
|
||||
{ selector: '#iNext', label: 'Next' },
|
||||
{ selector: '#iLooksGood', label: 'LooksGood' },
|
||||
{ selector: '#idSIButton9', label: 'PrimaryLoginButton' },
|
||||
{ selector: '.ms-Button.ms-Button--primary', label: 'Primary Generic' },
|
||||
{ selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Cancel' },
|
||||
{ selector: '.maybe-later, button[data-automation-id*="maybeLater" i]', label: 'Maybe Later' },
|
||||
{ selector: '#bnp_btn_reject', label: 'Bing Cookie Reject' },
|
||||
{ selector: '#bnp_btn_accept', label: 'Bing Cookie Accept' },
|
||||
{ selector: '#bnp_close_link', label: 'Bing Cookie Close' },
|
||||
{ selector: '#reward_pivot_earn', label: 'Rewards Pivot Earn' },
|
||||
{ selector: '//div[@id="cookieConsentContainer"]//button[contains(text(), "Accept")]', label: 'Legacy Cookie Accept', isXPath: true }
|
||||
]
|
||||
|
||||
private static readonly OVERLAY_SELECTORS = {
|
||||
container: '#bnp_overlay_wrapper',
|
||||
reject: '#bnp_btn_reject, button[aria-label*="Reject" i]',
|
||||
accept: '#bnp_btn_accept'
|
||||
} as const
|
||||
|
||||
private static readonly STREAK_DIALOG_SELECTORS = {
|
||||
container: '[role="dialog"], div[role="alert"], div.ms-Dialog',
|
||||
textFilter: /streak protection has run out/i,
|
||||
closeButtons: 'button[aria-label*="close" i], button:has-text("Close"), button:has-text("Dismiss"), button:has-text("Got it"), button:has-text("OK"), button:has-text("Ok")'
|
||||
} as const
|
||||
|
||||
private static readonly TERMS_UPDATE_SELECTORS = {
|
||||
titleId: '#iTOUTitle',
|
||||
titleText: /we're updating our terms/i,
|
||||
nextButton: 'button[data-testid="primaryButton"]:has-text("Next"), button[type="submit"]:has-text("Next")'
|
||||
} as const
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
async tryDismissAllMessages(page: Page): Promise<boolean> {
|
||||
const buttons = [
|
||||
{ selector: '#iLandingViewAction', label: 'iLandingViewAction' },
|
||||
{ selector: '#iShowSkip', label: 'iShowSkip' },
|
||||
{ selector: '#iNext', label: 'iNext' },
|
||||
{ selector: '#iLooksGood', label: 'iLooksGood' },
|
||||
{ selector: '#idSIButton9', label: 'idSIButton9' },
|
||||
{ selector: '.ms-Button.ms-Button--primary', label: 'Primary Button' }
|
||||
]
|
||||
|
||||
let result = false
|
||||
|
||||
for (const button of buttons) {
|
||||
try {
|
||||
const element = await page.waitForSelector(button.selector, { visible: true, timeout: 1000 })
|
||||
if (element) {
|
||||
await element.click()
|
||||
result = true
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async tryDismissCookieBanner(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.waitForSelector('#cookieConsentContainer', { timeout: 1000 })
|
||||
const cookieBanner = await page.$('#cookieConsentContainer')
|
||||
|
||||
if (cookieBanner) {
|
||||
const button = await cookieBanner.$('button')
|
||||
if (button) {
|
||||
await button.click()
|
||||
await this.bot.utils.wait(2000)
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Continue if element is not found or other error occurs
|
||||
async tryDismissAllMessages(page: Page): Promise<void> {
|
||||
const maxRounds = 3
|
||||
for (let round = 0; round < maxRounds; round++) {
|
||||
const dismissCount = await this.dismissRound(page)
|
||||
if (dismissCount === 0) break
|
||||
}
|
||||
}
|
||||
|
||||
async tryDismissBingCookieBanner(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.waitForSelector('#bnp_btn_accept', { timeout: 1000 })
|
||||
const cookieBanner = await page.$('#bnp_btn_accept')
|
||||
private async dismissRound(page: Page): Promise<number> {
|
||||
let count = 0
|
||||
count += await this.dismissStandardButtons(page)
|
||||
count += await this.dismissOverlayButtons(page)
|
||||
count += await this.dismissStreakDialog(page)
|
||||
count += await this.dismissTermsUpdateDialog(page)
|
||||
return count
|
||||
}
|
||||
|
||||
if (cookieBanner) {
|
||||
await cookieBanner.click()
|
||||
private async dismissStandardButtons(page: Page): Promise<number> {
|
||||
let count = 0
|
||||
for (const btn of BrowserUtil.DISMISS_BUTTONS) {
|
||||
const dismissed = await this.tryClickButton(page, btn)
|
||||
if (dismissed) {
|
||||
count++
|
||||
await page.waitForTimeout(150)
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue if element is not found or other error occurs
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
private async tryClickButton(page: Page, btn: DismissButton): Promise<boolean> {
|
||||
try {
|
||||
const loc = btn.isXPath ? page.locator(`xpath=${btn.selector}`) : page.locator(btn.selector)
|
||||
const visible = await loc.first().isVisible({ timeout: 200 }).catch(() => false)
|
||||
if (!visible) return false
|
||||
|
||||
await loc.first().click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', `Dismissed: ${btn.label}`)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async dismissOverlayButtons(page: Page): Promise<number> {
|
||||
try {
|
||||
const { container, reject, accept } = BrowserUtil.OVERLAY_SELECTORS
|
||||
const overlay = page.locator(container)
|
||||
const visible = await overlay.isVisible({ timeout: 200 }).catch(() => false)
|
||||
if (!visible) return 0
|
||||
|
||||
const rejectBtn = overlay.locator(reject)
|
||||
if (await rejectBtn.first().isVisible().catch(() => false)) {
|
||||
await rejectBtn.first().click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Reject')
|
||||
return 1
|
||||
}
|
||||
|
||||
const acceptBtn = overlay.locator(accept)
|
||||
if (await acceptBtn.first().isVisible().catch(() => false)) {
|
||||
await acceptBtn.first().click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Overlay Accept')
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private async dismissStreakDialog(page: Page): Promise<number> {
|
||||
try {
|
||||
const { container, textFilter, closeButtons } = BrowserUtil.STREAK_DIALOG_SELECTORS
|
||||
const dialog = page.locator(container).filter({ hasText: textFilter })
|
||||
const visible = await dialog.first().isVisible({ timeout: 200 }).catch(() => false)
|
||||
if (!visible) return 0
|
||||
|
||||
const closeBtn = dialog.locator(closeButtons).first()
|
||||
if (await closeBtn.isVisible({ timeout: 200 }).catch(() => false)) {
|
||||
await closeBtn.click({ timeout: 500 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Button')
|
||||
return 1
|
||||
}
|
||||
|
||||
await page.keyboard.press('Escape').catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Streak Protection Dialog Escape')
|
||||
return 1
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
private async dismissTermsUpdateDialog(page: Page): Promise<number> {
|
||||
try {
|
||||
const { titleId, titleText, nextButton } = BrowserUtil.TERMS_UPDATE_SELECTORS
|
||||
|
||||
// Check if terms update page is present
|
||||
const titleById = page.locator(titleId)
|
||||
const titleByText = page.locator('h1').filter({ hasText: titleText })
|
||||
|
||||
const hasTitle = await titleById.isVisible({ timeout: 200 }).catch(() => false) ||
|
||||
await titleByText.first().isVisible({ timeout: 200 }).catch(() => false)
|
||||
|
||||
if (!hasTitle) return 0
|
||||
|
||||
// Click the Next button
|
||||
const nextBtn = page.locator(nextButton).first()
|
||||
if (await nextBtn.isVisible({ timeout: 500 }).catch(() => false)) {
|
||||
await nextBtn.click({ timeout: 1000 }).catch(() => {})
|
||||
this.bot.log(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Terms Update Dialog (Next)')
|
||||
// Wait a bit for navigation
|
||||
await page.waitForTimeout(1000)
|
||||
return 1
|
||||
}
|
||||
|
||||
return 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
async getLatestTab(page: Page): Promise<Page> {
|
||||
try {
|
||||
await this.bot.utils.wait(500)
|
||||
await this.bot.utils.wait(1000)
|
||||
|
||||
const browser = page.browser()
|
||||
const pages = await browser.pages()
|
||||
const browser = page.context()
|
||||
const pages = browser.pages()
|
||||
const newTab = pages[pages.length - 1]
|
||||
|
||||
if (newTab) {
|
||||
return newTab
|
||||
}
|
||||
|
||||
throw this.bot.log('GET-NEW-TAB', 'Unable to get latest tab', 'error')
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'Unable to get latest tab', 'error')
|
||||
} catch (error) {
|
||||
throw this.bot.log('GET-NEW-TAB', 'An error occurred:' + error, 'error')
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-NEW-TAB', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async reloadBadPage(page: Page): Promise<void> {
|
||||
try {
|
||||
const html = await page.content().catch(() => '')
|
||||
const $ = load(html)
|
||||
|
||||
const isNetworkError = $('body.neterror').length
|
||||
|
||||
if (isNetworkError) {
|
||||
this.bot.log(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'Bad page detected, reloading!')
|
||||
await page.reload()
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
throw this.bot.log(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform small human-like gestures: short waits, minor mouse moves and occasional scrolls.
|
||||
* This should be called sparingly between actions to avoid a fixed cadence.
|
||||
*/
|
||||
async humanizePage(page: Page): Promise<void> {
|
||||
try {
|
||||
await this.bot.humanizer.microGestures(page)
|
||||
await this.bot.humanizer.actionPause()
|
||||
} catch { /* swallow */ }
|
||||
}
|
||||
|
||||
}
|
||||
122
src/config.json
122
src/config.json
@@ -1,27 +1,121 @@
|
||||
{
|
||||
"baseURL": "https://rewards.bing.com",
|
||||
"sessionPath": "sessions",
|
||||
"headless": false,
|
||||
"runOnZeroPoints": false,
|
||||
"clusters": 1,
|
||||
"dryRun": false,
|
||||
"browser": {
|
||||
"headless": false,
|
||||
"globalTimeout": "30s"
|
||||
},
|
||||
"fingerprinting": {
|
||||
"saveFingerprint": {
|
||||
"mobile": true,
|
||||
"desktop": true
|
||||
}
|
||||
},
|
||||
"execution": {
|
||||
"parallel": false,
|
||||
"runOnZeroPoints": false,
|
||||
"clusters": 1
|
||||
},
|
||||
"jobState": {
|
||||
"enabled": true,
|
||||
"dir": ""
|
||||
},
|
||||
"workers": {
|
||||
"doDailySet": true,
|
||||
"doMorePromotions": true,
|
||||
"doPunchCards": true,
|
||||
"doDesktopSearch": true,
|
||||
"doMobileSearch": true
|
||||
"doMobileSearch": true,
|
||||
"doDailyCheckIn": true,
|
||||
"doReadToEarn": true,
|
||||
"bundleDailySetWithSearch": true
|
||||
},
|
||||
"searchSettings": {
|
||||
"useGeoLocaleQueries": false,
|
||||
"scrollRandomResults": true,
|
||||
"clickRandomResults": true,
|
||||
"searchDelay": {
|
||||
"min": 10000,
|
||||
"max": 20000
|
||||
"search": {
|
||||
"useLocalQueries": true,
|
||||
"settings": {
|
||||
"useGeoLocaleQueries": true,
|
||||
"scrollRandomResults": true,
|
||||
"clickRandomResults": true,
|
||||
"retryMobileSearchAmount": 2,
|
||||
"delay": {
|
||||
"min": "2min",
|
||||
"max": "5min"
|
||||
}
|
||||
}
|
||||
},
|
||||
"webhook": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
"queryDiversity": {
|
||||
"enabled": true,
|
||||
"sources": [
|
||||
"google-trends",
|
||||
"reddit",
|
||||
"local-fallback"
|
||||
],
|
||||
"maxQueriesPerSource": 10,
|
||||
"cacheMinutes": 30
|
||||
},
|
||||
"humanization": {
|
||||
"enabled": true,
|
||||
"stopOnBan": true,
|
||||
"immediateBanAlert": true,
|
||||
"actionDelay": {
|
||||
"min": 500,
|
||||
"max": 2200
|
||||
},
|
||||
"gestureMoveProb": 0.65,
|
||||
"gestureScrollProb": 0.4,
|
||||
"allowedWindows": []
|
||||
},
|
||||
"vacation": {
|
||||
"enabled": true,
|
||||
"minDays": 2,
|
||||
"maxDays": 4
|
||||
},
|
||||
"riskManagement": {
|
||||
"enabled": true,
|
||||
"autoAdjustDelays": true,
|
||||
"stopOnCritical": false,
|
||||
"banPrediction": true,
|
||||
"riskThreshold": 75
|
||||
},
|
||||
"retryPolicy": {
|
||||
"maxAttempts": 3,
|
||||
"baseDelay": 1000,
|
||||
"maxDelay": "30s",
|
||||
"multiplier": 2,
|
||||
"jitter": 0.2
|
||||
},
|
||||
"proxy": {
|
||||
"proxyGoogleTrends": true,
|
||||
"proxyBingTerms": true
|
||||
},
|
||||
"notifications": {
|
||||
"webhook": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
},
|
||||
"conclusionWebhook": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
},
|
||||
"ntfy": {
|
||||
"enabled": false,
|
||||
"url": "",
|
||||
"topic": "rewards",
|
||||
"authToken": ""
|
||||
}
|
||||
},
|
||||
"logging": {
|
||||
"excludeFunc": [
|
||||
"SEARCH-CLOSE-TABS",
|
||||
"LOGIN-NO-PROMPT",
|
||||
"FLOW"
|
||||
],
|
||||
"webhookExcludeFunc": [
|
||||
"SEARCH-CLOSE-TABS",
|
||||
"LOGIN-NO-PROMPT",
|
||||
"FLOW"
|
||||
],
|
||||
"redactEmails": true
|
||||
}
|
||||
}
|
||||
74
src/constants.ts
Normal file
74
src/constants.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Central constants file for the Microsoft Rewards Script
|
||||
* Defines timeouts, retry limits, and other magic numbers used throughout the application
|
||||
*/
|
||||
|
||||
export const TIMEOUTS = {
|
||||
SHORT: 500,
|
||||
MEDIUM: 1500,
|
||||
MEDIUM_LONG: 2000,
|
||||
LONG: 3000,
|
||||
VERY_LONG: 5000,
|
||||
EXTRA_LONG: 10000,
|
||||
DASHBOARD_WAIT: 10000,
|
||||
LOGIN_MAX: 180000, // 3 minutes
|
||||
NETWORK_IDLE: 5000
|
||||
} as const
|
||||
|
||||
export const RETRY_LIMITS = {
|
||||
MAX_ITERATIONS: 5,
|
||||
DASHBOARD_RELOAD: 2,
|
||||
MOBILE_SEARCH: 3,
|
||||
ABC_MAX: 15,
|
||||
POLL_MAX: 15,
|
||||
QUIZ_MAX: 15,
|
||||
QUIZ_ANSWER_TIMEOUT: 10000,
|
||||
GO_HOME_MAX: 5
|
||||
} as const
|
||||
|
||||
export const DELAYS = {
|
||||
ACTION_MIN: 1000,
|
||||
ACTION_MAX: 3000,
|
||||
SEARCH_DEFAULT_MIN: 2000,
|
||||
SEARCH_DEFAULT_MAX: 5000,
|
||||
BROWSER_CLOSE: 2000,
|
||||
TYPING_DELAY: 20,
|
||||
SEARCH_ON_BING_WAIT: 5000,
|
||||
SEARCH_ON_BING_COMPLETE: 3000,
|
||||
SEARCH_ON_BING_FOCUS: 200,
|
||||
SEARCH_BAR_TIMEOUT: 15000,
|
||||
QUIZ_ANSWER_WAIT: 2000,
|
||||
THIS_OR_THAT_START: 2000
|
||||
} as const
|
||||
|
||||
export const SELECTORS = {
|
||||
MORE_ACTIVITIES: '#more-activities',
|
||||
SUSPENDED_ACCOUNT: '#suspendedAccountHeader',
|
||||
QUIZ_COMPLETE: '#quizCompleteContainer',
|
||||
QUIZ_CREDITS: 'span.rqMCredits'
|
||||
} as const
|
||||
|
||||
export const URLS = {
|
||||
REWARDS_BASE: 'https://rewards.bing.com',
|
||||
REWARDS_SIGNIN: 'https://rewards.bing.com/signin',
|
||||
APP_USER_DATA: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613'
|
||||
} as const
|
||||
|
||||
export const DISCORD = {
|
||||
MAX_EMBED_LENGTH: 1900,
|
||||
RATE_LIMIT_DELAY: 500,
|
||||
WEBHOOK_TIMEOUT: 10000,
|
||||
DEBOUNCE_DELAY: 750,
|
||||
COLOR_RED: 0xFF0000,
|
||||
COLOR_CRIMSON: 0xDC143C,
|
||||
COLOR_ORANGE: 0xFFA500,
|
||||
COLOR_BLUE: 0x3498DB,
|
||||
COLOR_GREEN: 0x00D26A,
|
||||
AVATAR_URL: 'https://media.discordapp.net/attachments/1421163952972369931/1421929950377939125/Gc.png'
|
||||
} as const
|
||||
|
||||
export const META = {
|
||||
|
||||
C: 'aHR0cHM6Ly9kaXNjb3JkLmdnL2tuMzY5NUt4MzI=',
|
||||
R: 'aHR0cHM6Ly9naXRodWIuY29tL0xpZ2h0NjAtMS9NaWNyb3NvZnQtUmV3YXJkcy1SZXdp'
|
||||
} as const
|
||||
2
src/crontab.template
Normal file
2
src/crontab.template
Normal file
@@ -0,0 +1,2 @@
|
||||
# Run automation according to CRON_SCHEDULE; redirect both stdout & stderr to Docker logs
|
||||
${CRON_SCHEDULE} TZ=${TZ} /bin/bash /usr/src/microsoft-rewards-script/src/run_daily.sh >> /proc/1/fd/1 2>&1
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
|
||||
@@ -8,20 +8,117 @@ import { Poll } from './activities/Poll'
|
||||
import { Quiz } from './activities/Quiz'
|
||||
import { ThisOrThat } from './activities/ThisOrThat'
|
||||
import { UrlReward } from './activities/UrlReward'
|
||||
import { SearchOnBing } from './activities/SearchOnBing'
|
||||
import { ReadToEarn } from './activities/ReadToEarn'
|
||||
import { DailyCheckIn } from './activities/DailyCheckIn'
|
||||
|
||||
import { DashboardData } from '../interface/DashboardData'
|
||||
import { DashboardData, MorePromotion, PromotionalItem } from '../interface/DashboardData'
|
||||
import type { ActivityHandler } from '../interface/ActivityHandler'
|
||||
|
||||
type ActivityKind =
|
||||
| { type: 'poll' }
|
||||
| { type: 'abc' }
|
||||
| { type: 'thisOrThat' }
|
||||
| { type: 'quiz' }
|
||||
| { type: 'urlReward' }
|
||||
| { type: 'searchOnBing' }
|
||||
| { type: 'unsupported' }
|
||||
|
||||
|
||||
export default class Activities {
|
||||
private bot: MicrosoftRewardsBot
|
||||
private handlers: ActivityHandler[] = []
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
doSearch = async (page: Page, data: DashboardData, mobile: boolean): Promise<void> => {
|
||||
// Register external/custom handlers (optional extension point)
|
||||
registerHandler(handler: ActivityHandler) {
|
||||
this.handlers.push(handler)
|
||||
}
|
||||
|
||||
// Centralized dispatcher for activities from dashboard/punchcards
|
||||
async run(page: Page, activity: MorePromotion | PromotionalItem): Promise<void> {
|
||||
// First, try custom handlers (if any)
|
||||
for (const h of this.handlers) {
|
||||
try {
|
||||
if (h.canHandle(activity)) {
|
||||
await h.run(page, activity)
|
||||
return
|
||||
}
|
||||
} catch (e) {
|
||||
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Custom handler ${(h.id || 'unknown')} failed: ${e instanceof Error ? e.message : e}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const kind = this.classifyActivity(activity)
|
||||
try {
|
||||
switch (kind.type) {
|
||||
case 'poll':
|
||||
await this.doPoll(page)
|
||||
break
|
||||
case 'abc':
|
||||
await this.doABC(page)
|
||||
break
|
||||
case 'thisOrThat':
|
||||
await this.doThisOrThat(page)
|
||||
break
|
||||
case 'quiz':
|
||||
await this.doQuiz(page)
|
||||
break
|
||||
case 'searchOnBing':
|
||||
await this.doSearchOnBing(page, activity)
|
||||
break
|
||||
case 'urlReward':
|
||||
await this.doUrlReward(page)
|
||||
break
|
||||
default:
|
||||
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${String((activity as { promotionType?: string }).promotionType)}"!`, 'warn')
|
||||
break
|
||||
}
|
||||
} catch (e) {
|
||||
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Dispatcher error for "${activity.title}": ${e instanceof Error ? e.message : e}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
public getTypeLabel(activity: MorePromotion | PromotionalItem): string {
|
||||
const k = this.classifyActivity(activity)
|
||||
switch (k.type) {
|
||||
case 'poll': return 'Poll'
|
||||
case 'abc': return 'ABC'
|
||||
case 'thisOrThat': return 'ThisOrThat'
|
||||
case 'quiz': return 'Quiz'
|
||||
case 'searchOnBing': return 'SearchOnBing'
|
||||
case 'urlReward': return 'UrlReward'
|
||||
default: return 'Unsupported'
|
||||
}
|
||||
}
|
||||
|
||||
private classifyActivity(activity: MorePromotion | PromotionalItem): ActivityKind {
|
||||
const type = (activity.promotionType || '').toLowerCase()
|
||||
if (type === 'quiz') {
|
||||
// Distinguish Poll/ABC/ThisOrThat vs general quiz using current heuristics
|
||||
const max = activity.pointProgressMax
|
||||
const url = (activity.destinationUrl || '').toLowerCase()
|
||||
if (max === 10) {
|
||||
if (url.includes('pollscenarioid')) return { type: 'poll' }
|
||||
return { type: 'abc' }
|
||||
}
|
||||
if (max === 50) return { type: 'thisOrThat' }
|
||||
return { type: 'quiz' }
|
||||
}
|
||||
if (type === 'urlreward') {
|
||||
const name = (activity.name || '').toLowerCase()
|
||||
if (name.includes('exploreonbing')) return { type: 'searchOnBing' }
|
||||
return { type: 'urlReward' }
|
||||
}
|
||||
return { type: 'unsupported' }
|
||||
}
|
||||
|
||||
doSearch = async (page: Page, data: DashboardData): Promise<void> => {
|
||||
const search = new Search(this.bot)
|
||||
await search.doSearch(page, data, mobile)
|
||||
await search.doSearch(page, data)
|
||||
}
|
||||
|
||||
doABC = async (page: Page): Promise<void> => {
|
||||
@@ -49,4 +146,19 @@ export default class Activities {
|
||||
await urlReward.doUrlReward(page)
|
||||
}
|
||||
|
||||
doSearchOnBing = async (page: Page, activity: MorePromotion | PromotionalItem): Promise<void> => {
|
||||
const searchOnBing = new SearchOnBing(this.bot)
|
||||
await searchOnBing.doSearchOnBing(page, activity)
|
||||
}
|
||||
|
||||
doReadToEarn = async (accessToken: string, data: DashboardData): Promise<void> => {
|
||||
const readToEarn = new ReadToEarn(this.bot)
|
||||
await readToEarn.doReadToEarn(accessToken, data)
|
||||
}
|
||||
|
||||
doDailyCheckIn = async (accessToken: string, data: DashboardData): Promise<void> => {
|
||||
const dailyCheckIn = new DailyCheckIn(this.bot)
|
||||
await dailyCheckIn.doDailyCheckIn(accessToken, data)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,133 +1,878 @@
|
||||
import { Page } from 'puppeteer'
|
||||
// Clean refactored Login implementation
|
||||
// Public API preserved: login(), getMobileAccessToken()
|
||||
|
||||
import type { Page, Locator } from 'playwright'
|
||||
import * as crypto from 'crypto'
|
||||
import readline from 'readline'
|
||||
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
import { generateTOTP } from '../util/Totp'
|
||||
import { saveSessionData } from '../util/Load'
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import { OAuth } from '../interface/OAuth'
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
// -------------------------------
|
||||
// Constants / Tunables
|
||||
// -------------------------------
|
||||
const SELECTORS = {
|
||||
emailInput: 'input[type="email"]',
|
||||
passwordInput: 'input[type="password"]',
|
||||
submitBtn: 'button[type="submit"]',
|
||||
passkeySecondary: 'button[data-testid="secondaryButton"]',
|
||||
passkeyPrimary: 'button[data-testid="primaryButton"]',
|
||||
passkeyTitle: '[data-testid="title"]',
|
||||
kmsiVideo: '[data-testid="kmsiVideo"]',
|
||||
biometricVideo: '[data-testid="biometricVideo"]'
|
||||
} as const
|
||||
|
||||
const LOGIN_TARGET = { host: 'rewards.bing.com', path: '/' }
|
||||
|
||||
const DEFAULT_TIMEOUTS = {
|
||||
loginMaxMs: (() => {
|
||||
const val = Number(process.env.LOGIN_MAX_WAIT_MS || 180000)
|
||||
if (isNaN(val) || val < 10000 || val > 600000) {
|
||||
console.warn(`[Login] Invalid LOGIN_MAX_WAIT_MS: ${process.env.LOGIN_MAX_WAIT_MS}. Using default 180000ms`)
|
||||
return 180000
|
||||
}
|
||||
return val
|
||||
})(),
|
||||
short: 500,
|
||||
medium: 1500,
|
||||
long: 3000
|
||||
}
|
||||
|
||||
// Security pattern bundle
|
||||
const SIGN_IN_BLOCK_PATTERNS: { re: RegExp; label: string }[] = [
|
||||
{ re: /we can['’`]?t sign you in/i, label: 'cant-sign-in' },
|
||||
{ re: /incorrect account or password too many times/i, label: 'too-many-incorrect' },
|
||||
{ re: /used an incorrect account or password too many times/i, label: 'too-many-incorrect-variant' },
|
||||
{ re: /sign-in has been blocked/i, label: 'sign-in-blocked-phrase' },
|
||||
{ re: /your account has been locked/i, label: 'account-locked' },
|
||||
{ re: /your account or password is incorrect too many times/i, label: 'incorrect-too-many-times' }
|
||||
]
|
||||
|
||||
interface SecurityIncident {
|
||||
kind: string
|
||||
account: string
|
||||
details?: string[]
|
||||
next?: string[]
|
||||
docsUrl?: string
|
||||
}
|
||||
|
||||
export class Login {
|
||||
private bot: MicrosoftRewardsBot
|
||||
private bot: MicrosoftRewardsBot
|
||||
private clientId = '0000000040170455'
|
||||
private authBaseUrl = 'https://login.live.com/oauth20_authorize.srf'
|
||||
private redirectUrl = 'https://login.live.com/oauth20_desktop.srf'
|
||||
private tokenUrl = 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token'
|
||||
private scope = 'service::prod.rewardsplatform.microsoft.com::MBI_SSL'
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
private currentTotpSecret?: string
|
||||
private compromisedInterval?: NodeJS.Timeout
|
||||
private passkeyHandled = false
|
||||
private noPromptIterations = 0
|
||||
private lastNoPromptLog = 0
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) { this.bot = bot }
|
||||
|
||||
// --------------- Public API ---------------
|
||||
async login(page: Page, email: string, password: string, totpSecret?: string) {
|
||||
try {
|
||||
// Clear any existing intervals from previous runs
|
||||
if (this.compromisedInterval) {
|
||||
clearInterval(this.compromisedInterval)
|
||||
this.compromisedInterval = undefined
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Starting login process')
|
||||
this.currentTotpSecret = (totpSecret && totpSecret.trim()) || undefined
|
||||
|
||||
await page.goto('https://www.bing.com/rewards/dashboard')
|
||||
await this.disableFido(page)
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => { })
|
||||
await this.bot.browser.utils.reloadBadPage(page)
|
||||
await this.checkAccountLocked(page)
|
||||
|
||||
const already = await page.waitForSelector('html[data-role-name="RewardsPortal"]', { timeout: 8000 }).then(() => true).catch(() => false)
|
||||
if (!already) {
|
||||
await this.performLoginFlow(page, email, password)
|
||||
} else {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Session already authenticated')
|
||||
await this.checkAccountLocked(page)
|
||||
}
|
||||
|
||||
await this.verifyBingContext(page)
|
||||
await saveSessionData(this.bot.config.sessionPath, page.context(), email, this.bot.isMobile)
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Login complete (session saved)')
|
||||
this.currentTotpSecret = undefined
|
||||
} catch (e) {
|
||||
throw this.bot.log(this.bot.isMobile, 'LOGIN', 'Failed login: ' + e, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async getMobileAccessToken(page: Page, email: string) {
|
||||
// Reuse same FIDO disabling
|
||||
await this.disableFido(page)
|
||||
const url = new URL(this.authBaseUrl)
|
||||
url.searchParams.set('response_type', 'code')
|
||||
url.searchParams.set('client_id', this.clientId)
|
||||
url.searchParams.set('redirect_uri', this.redirectUrl)
|
||||
url.searchParams.set('scope', this.scope)
|
||||
url.searchParams.set('state', crypto.randomBytes(16).toString('hex'))
|
||||
url.searchParams.set('access_type', 'offline_access')
|
||||
url.searchParams.set('login_hint', email)
|
||||
|
||||
await page.goto(url.href)
|
||||
const start = Date.now()
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'Authorizing mobile scope...')
|
||||
let code = ''
|
||||
while (Date.now() - start < DEFAULT_TIMEOUTS.loginMaxMs) {
|
||||
await this.handlePasskeyPrompts(page, 'oauth')
|
||||
const u = new URL(page.url())
|
||||
if (u.hostname === 'login.live.com' && u.pathname === '/oauth20_desktop.srf') {
|
||||
code = u.searchParams.get('code') || ''
|
||||
break
|
||||
}
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
if (!code) throw this.bot.log(this.bot.isMobile, 'LOGIN-APP', 'OAuth code not received in time', 'error')
|
||||
|
||||
const form = new URLSearchParams()
|
||||
form.append('grant_type', 'authorization_code')
|
||||
form.append('client_id', this.clientId)
|
||||
form.append('code', code)
|
||||
form.append('redirect_uri', this.redirectUrl)
|
||||
|
||||
const req: AxiosRequestConfig = { url: this.tokenUrl, method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: form.toString() }
|
||||
const resp = await this.bot.axios.request(req)
|
||||
const data: OAuth = resp.data
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-APP', `Authorized in ${Math.round((Date.now() - start) / 1000)}s`)
|
||||
return data.access_token
|
||||
}
|
||||
|
||||
// --------------- Main Flow ---------------
|
||||
private async performLoginFlow(page: Page, email: string, password: string) {
|
||||
await this.inputEmail(page, email)
|
||||
await this.bot.utils.wait(1000)
|
||||
await this.bot.browser.utils.reloadBadPage(page)
|
||||
await this.bot.utils.wait(500)
|
||||
await this.tryRecoveryMismatchCheck(page, email)
|
||||
if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'recovery-mismatch') {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Recovery mismatch detected – stopping before password entry', 'warn')
|
||||
return
|
||||
}
|
||||
// Try switching to password if a locale link is present (FR/EN)
|
||||
await this.switchToPasswordLink(page)
|
||||
await this.inputPasswordOr2FA(page, password)
|
||||
if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Blocked sign-in detected — halting.', 'warn')
|
||||
return
|
||||
}
|
||||
await this.checkAccountLocked(page)
|
||||
await this.awaitRewardsPortal(page)
|
||||
}
|
||||
|
||||
// --------------- Input Steps ---------------
|
||||
private async inputEmail(page: Page, email: string) {
|
||||
const field = await page.waitForSelector(SELECTORS.emailInput, { timeout: 5000 }).catch(() => null)
|
||||
if (!field) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Email field not present', 'warn'); return }
|
||||
const prefilled = await page.waitForSelector('#userDisplayName', { timeout: 1500 }).catch(() => null)
|
||||
if (!prefilled) {
|
||||
await page.fill(SELECTORS.emailInput, '')
|
||||
await page.fill(SELECTORS.emailInput, email)
|
||||
} else {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Email prefilled')
|
||||
}
|
||||
const next = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(() => null)
|
||||
if (next) { await next.click().catch(() => { }); this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted email') }
|
||||
}
|
||||
|
||||
private async inputPasswordOr2FA(page: Page, password: string) {
|
||||
// Some flows require switching to password first
|
||||
const switchBtn = await page.waitForSelector('#idA_PWD_SwitchToPassword', { timeout: 1500 }).catch(() => null)
|
||||
if (switchBtn) { await switchBtn.click().catch(() => { }); await this.bot.utils.wait(1000) }
|
||||
|
||||
// Rare flow: list of methods -> choose password
|
||||
const passwordField = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 4000 }).catch(() => null)
|
||||
if (!passwordField) {
|
||||
const blocked = await this.detectSignInBlocked(page)
|
||||
if (blocked) return
|
||||
|
||||
// Log that we're handling the "Get a code to sign in" flow
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Attempting to handle "Get a code to sign in" flow')
|
||||
|
||||
// Try to handle "Other ways to sign in" flow first
|
||||
const otherWaysHandled = await this.handleOtherWaysToSignIn(page)
|
||||
if (otherWaysHandled) {
|
||||
// Try to find password field again after clicking "Other ways"
|
||||
const passwordFieldAfter = await page.waitForSelector(SELECTORS.passwordInput, { timeout: 3000 }).catch(() => null)
|
||||
if (passwordFieldAfter) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Password field found after "Other ways" flow')
|
||||
await page.fill(SELECTORS.passwordInput, '')
|
||||
await page.fill(SELECTORS.passwordInput, password)
|
||||
const submit = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(() => null)
|
||||
if (submit) { await submit.click().catch(() => { }); this.bot.log(this.bot.isMobile, 'LOGIN', 'Password submitted') }
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If still no password field -> likely 2FA (approvals) first
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Password field absent — invoking 2FA handler', 'warn')
|
||||
await this.handle2FA(page)
|
||||
return
|
||||
}
|
||||
|
||||
async login(page: Page, email: string, password: string) {
|
||||
const blocked = await this.detectSignInBlocked(page)
|
||||
if (blocked) return
|
||||
|
||||
await page.fill(SELECTORS.passwordInput, '')
|
||||
await page.fill(SELECTORS.passwordInput, password)
|
||||
const submit = await page.waitForSelector(SELECTORS.submitBtn, { timeout: 2000 }).catch(() => null)
|
||||
if (submit) { await submit.click().catch(() => { }); this.bot.log(this.bot.isMobile, 'LOGIN', 'Password submitted') }
|
||||
}
|
||||
|
||||
|
||||
// --------------- Other Ways to Sign In Handling ---------------
|
||||
private async handleOtherWaysToSignIn(page: Page): Promise<boolean> {
|
||||
try {
|
||||
// Look for "Other ways to sign in" - typically a span with role="button"
|
||||
const otherWaysSelectors = [
|
||||
'span[role="button"]:has-text("Other ways to sign in")',
|
||||
'span:has-text("Other ways to sign in")',
|
||||
'button:has-text("Other ways to sign in")',
|
||||
'a:has-text("Other ways to sign in")',
|
||||
'div[role="button"]:has-text("Other ways to sign in")'
|
||||
]
|
||||
|
||||
let clicked = false
|
||||
for (const selector of otherWaysSelectors) {
|
||||
const element = await page.waitForSelector(selector, { timeout: 1000 }).catch(() => null)
|
||||
if (element && await element.isVisible().catch(() => false)) {
|
||||
await element.click().catch(() => { })
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Clicked "Other ways to sign in"')
|
||||
await this.bot.utils.wait(2000) // Wait for options to appear
|
||||
clicked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Now look for "Use your password" option
|
||||
const usePasswordSelectors = [
|
||||
'span[role="button"]:has-text("Use your password")',
|
||||
'span:has-text("Use your password")',
|
||||
'button:has-text("Use your password")',
|
||||
'button:has-text("Password")',
|
||||
'a:has-text("Use your password")',
|
||||
'div[role="button"]:has-text("Use your password")',
|
||||
'div[role="button"]:has-text("Password")'
|
||||
]
|
||||
|
||||
for (const selector of usePasswordSelectors) {
|
||||
const element = await page.waitForSelector(selector, { timeout: 1500 }).catch(() => null)
|
||||
if (element && await element.isVisible().catch(() => false)) {
|
||||
await element.click().catch(() => { })
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Clicked "Use your password"')
|
||||
await this.bot.utils.wait(2000) // Wait for password field to appear
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Error in handleOtherWaysToSignIn: ' + error, 'warn')
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// --------------- 2FA Handling ---------------
|
||||
private async handle2FA(page: Page) {
|
||||
try {
|
||||
// Dismiss any popups/dialogs before checking 2FA (Terms Update, etc.)
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
await this.bot.utils.wait(500)
|
||||
|
||||
if (this.currentTotpSecret) {
|
||||
const totpSelector = await this.ensureTotpInput(page)
|
||||
if (totpSelector) {
|
||||
await this.submitTotpCode(page, totpSelector)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const number = await this.fetchAuthenticatorNumber(page)
|
||||
if (number) { await this.approveAuthenticator(page, number); return }
|
||||
await this.handleSMSOrTotp(page)
|
||||
} catch (e) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA error: ' + e, 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchAuthenticatorNumber(page: Page): Promise<string | null> {
|
||||
try {
|
||||
const el = await page.waitForSelector('#displaySign, div[data-testid="displaySign"]>span', { timeout: 2500 })
|
||||
return (await el.textContent())?.trim() || null
|
||||
} catch {
|
||||
// Attempt resend loop in parallel mode
|
||||
if (this.bot.config.parallel) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Parallel mode: throttling authenticator push requests', 'log', 'yellow')
|
||||
for (let attempts = 0; attempts < 6; attempts++) { // max 6 minutes retry window
|
||||
const resend = await page.waitForSelector('button[aria-describedby="pushNotificationsTitle errorDescription"]', { timeout: 1500 }).catch(() => null)
|
||||
if (!resend) break
|
||||
await this.bot.utils.wait(60000)
|
||||
await resend.click().catch(() => { })
|
||||
}
|
||||
}
|
||||
await page.click('button[aria-describedby="confirmSendTitle"]').catch(() => { })
|
||||
await this.bot.utils.wait(1500)
|
||||
try {
|
||||
const el = await page.waitForSelector('#displaySign, div[data-testid="displaySign"]>span', { timeout: 2000 })
|
||||
return (await el.textContent())?.trim() || null
|
||||
} catch { return null }
|
||||
}
|
||||
}
|
||||
|
||||
private async approveAuthenticator(page: Page, numberToPress: string) {
|
||||
for (let cycle = 0; cycle < 6; cycle++) { // max ~6 refresh cycles
|
||||
try {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', `Approve login in Authenticator (press ${numberToPress})`)
|
||||
await page.waitForSelector('form[name="f1"]', { state: 'detached', timeout: 60000 })
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator approval successful')
|
||||
return
|
||||
} catch {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator code expired – refreshing')
|
||||
const retryBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 3000 }).catch(() => null)
|
||||
if (retryBtn) await retryBtn.click().catch(() => { })
|
||||
const refreshed = await this.fetchAuthenticatorNumber(page)
|
||||
if (!refreshed) { this.bot.log(this.bot.isMobile, 'LOGIN', 'Could not refresh authenticator code', 'warn'); return }
|
||||
numberToPress = refreshed
|
||||
}
|
||||
}
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Authenticator approval loop exited (max cycles reached)', 'warn')
|
||||
}
|
||||
|
||||
private async handleSMSOrTotp(page: Page) {
|
||||
// TOTP auto entry (second chance if ensureTotpInput needed longer)
|
||||
if (this.currentTotpSecret) {
|
||||
try {
|
||||
const totpSelector = await this.ensureTotpInput(page)
|
||||
if (totpSelector) {
|
||||
await this.submitTotpCode(page, totpSelector)
|
||||
return
|
||||
}
|
||||
} catch {/* ignore */ }
|
||||
}
|
||||
|
||||
// Manual prompt with periodic page check
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Waiting for user 2FA code (SMS / Email / App fallback)')
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
|
||||
|
||||
// Monitor page changes while waiting for user input
|
||||
let userInput: string | null = null
|
||||
let checkInterval: NodeJS.Timeout | null = null
|
||||
|
||||
try {
|
||||
const inputPromise = new Promise<string>(res => {
|
||||
rl.question('Enter 2FA code:\n', ans => {
|
||||
if (checkInterval) clearInterval(checkInterval)
|
||||
rl.close()
|
||||
res(ans.trim())
|
||||
})
|
||||
})
|
||||
|
||||
// Check every 2 seconds if user manually progressed past the dialog
|
||||
checkInterval = setInterval(async () => {
|
||||
try {
|
||||
// Navigate to the Bing login page
|
||||
await page.goto('https://login.live.com/')
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
// Check if we're no longer on 2FA page
|
||||
const still2FA = await page.locator('input[name="otc"]').first().isVisible({ timeout: 500 }).catch(() => false)
|
||||
if (!still2FA) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Page changed during 2FA wait (user may have clicked Next)', 'warn')
|
||||
if (checkInterval) clearInterval(checkInterval)
|
||||
rl.close()
|
||||
userInput = 'skip' // Signal to skip submission
|
||||
}
|
||||
} catch {/* ignore */ }
|
||||
}, 2000)
|
||||
|
||||
const isLoggedIn = await page.waitForSelector('html[data-role-name="MeePortal"]', { timeout: 10_000 }).then(() => true).catch(() => false)
|
||||
const code = await inputPromise
|
||||
|
||||
if (!isLoggedIn) {
|
||||
const isLocked = await page.waitForSelector('.serviceAbusePageContainer', { visible: true, timeout: 10_000 }).then(() => true).catch(() => false)
|
||||
if (isLocked) {
|
||||
this.bot.log('LOGIN', 'This account has been locked!', 'error')
|
||||
throw new Error('Account has been locked!')
|
||||
}
|
||||
if (code === 'skip' || userInput === 'skip') {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Skipping 2FA code submission (page progressed)')
|
||||
return
|
||||
}
|
||||
|
||||
await page.waitForSelector('#loginHeader', { visible: true, timeout: 10_000 })
|
||||
await page.fill('input[name="otc"]', code)
|
||||
await page.keyboard.press('Enter')
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', '2FA code submitted')
|
||||
} finally {
|
||||
// Ensure cleanup happens even if errors occur
|
||||
if (checkInterval) clearInterval(checkInterval)
|
||||
try { rl.close() } catch {/* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
await this.execLogin(page, email, password)
|
||||
this.bot.log('LOGIN', 'Logged into Microsoft successfully')
|
||||
} else {
|
||||
this.bot.log('LOGIN', 'Already logged in')
|
||||
}
|
||||
private async ensureTotpInput(page: Page): Promise<string | null> {
|
||||
const selector = await this.findFirstVisibleSelector(page, this.totpInputSelectors())
|
||||
if (selector) return selector
|
||||
|
||||
// Check if logged in to bing
|
||||
await this.checkBingLogin(page)
|
||||
const attempts = 4
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
let acted = false
|
||||
|
||||
// We're done logging in
|
||||
this.bot.log('LOGIN', 'Logged in successfully')
|
||||
// Step 1: expose alternative verification options if hidden
|
||||
if (!acted) {
|
||||
acted = await this.clickFirstVisibleSelector(page, this.totpAltOptionSelectors())
|
||||
if (acted) await this.bot.utils.wait(900)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Throw and don't continue
|
||||
throw this.bot.log('LOGIN', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
// Step 2: choose authenticator code option if available
|
||||
if (!acted) {
|
||||
acted = await this.clickFirstVisibleSelector(page, this.totpChallengeSelectors())
|
||||
if (acted) await this.bot.utils.wait(900)
|
||||
}
|
||||
|
||||
const ready = await this.findFirstVisibleSelector(page, this.totpInputSelectors())
|
||||
if (ready) return ready
|
||||
|
||||
if (!acted) break
|
||||
}
|
||||
|
||||
private async execLogin(page: Page, email: string, password: string) {
|
||||
await page.type('#i0116', email)
|
||||
await page.click('#idSIButton9')
|
||||
return null
|
||||
}
|
||||
|
||||
this.bot.log('LOGIN', 'Email entered successfully')
|
||||
private async submitTotpCode(page: Page, selector: string) {
|
||||
try {
|
||||
const code = generateTOTP(this.currentTotpSecret!.trim())
|
||||
const input = page.locator(selector).first()
|
||||
if (!await input.isVisible().catch(() => false)) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'TOTP input unexpectedly hidden', 'warn')
|
||||
return
|
||||
}
|
||||
await input.fill('')
|
||||
await input.fill(code)
|
||||
// Use unified selector system
|
||||
const submit = await this.findFirstVisibleLocator(page, Login.TOTP_SELECTORS.submit)
|
||||
if (submit) {
|
||||
await submit.click().catch(() => { })
|
||||
} else {
|
||||
await page.keyboard.press('Enter').catch(() => { })
|
||||
}
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Submitted TOTP automatically')
|
||||
} catch (error) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Failed to submit TOTP automatically: ' + error, 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
// Unified selector system - DRY principle
|
||||
private static readonly TOTP_SELECTORS = {
|
||||
input: [
|
||||
'input[name="otc"]',
|
||||
'#idTxtBx_SAOTCC_OTC',
|
||||
'#idTxtBx_SAOTCS_OTC',
|
||||
'input[data-testid="otcInput"]',
|
||||
'input[autocomplete="one-time-code"]',
|
||||
'input[type="tel"][name="otc"]'
|
||||
],
|
||||
altOptions: [
|
||||
'#idA_SAOTCS_ProofPickerChange',
|
||||
'#idA_SAOTCC_AlternateLogin',
|
||||
'a:has-text("Use a different verification option")',
|
||||
'a:has-text("Sign in another way")',
|
||||
'a:has-text("I can\'t use my Microsoft Authenticator app right now")',
|
||||
'button:has-text("Use a different verification option")',
|
||||
'button:has-text("Sign in another way")'
|
||||
],
|
||||
challenge: [
|
||||
'[data-value="PhoneAppOTP"]',
|
||||
'[data-value="OneTimeCode"]',
|
||||
'button:has-text("Use a verification code")',
|
||||
'button:has-text("Enter code manually")',
|
||||
'button:has-text("Enter a code from your authenticator app")',
|
||||
'button:has-text("Use code from your authentication app")',
|
||||
'button:has-text("Utiliser un code de vérification")',
|
||||
'button:has-text("Utiliser un code de verification")',
|
||||
'button:has-text("Entrer un code depuis votre application")',
|
||||
'button:has-text("Entrez un code depuis votre application")',
|
||||
'button:has-text("Entrez un code")',
|
||||
'div[role="button"]:has-text("Use a verification code")',
|
||||
'div[role="button"]:has-text("Enter a code")'
|
||||
],
|
||||
submit: [
|
||||
'#idSubmit_SAOTCC_Continue',
|
||||
'#idSubmit_SAOTCC_OTC',
|
||||
'button[type="submit"]:has-text("Verify")',
|
||||
'button[type="submit"]:has-text("Continuer")',
|
||||
'button:has-text("Verify")',
|
||||
'button:has-text("Continuer")',
|
||||
'button:has-text("Submit")'
|
||||
]
|
||||
} as const
|
||||
|
||||
private totpInputSelectors(): readonly string[] { return Login.TOTP_SELECTORS.input }
|
||||
private totpAltOptionSelectors(): readonly string[] { return Login.TOTP_SELECTORS.altOptions }
|
||||
private totpChallengeSelectors(): readonly string[] { return Login.TOTP_SELECTORS.challenge }
|
||||
|
||||
// Generic selector finder - reduces duplication from 3 functions to 1
|
||||
private async findFirstVisibleSelector(page: Page, selectors: readonly string[]): Promise<string | null> {
|
||||
for (const sel of selectors) {
|
||||
const loc = page.locator(sel).first()
|
||||
if (await loc.isVisible().catch(() => false)) return sel
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async clickFirstVisibleSelector(page: Page, selectors: readonly string[]): Promise<boolean> {
|
||||
for (const sel of selectors) {
|
||||
const loc = page.locator(sel).first()
|
||||
if (await loc.isVisible().catch(() => false)) {
|
||||
await loc.click().catch(() => { })
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private async findFirstVisibleLocator(page: Page, selectors: readonly string[]): Promise<Locator | null> {
|
||||
for (const sel of selectors) {
|
||||
const loc = page.locator(sel).first()
|
||||
if (await loc.isVisible().catch(() => false)) return loc
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async waitForRewardsRoot(page: Page, timeoutMs: number): Promise<string | null> {
|
||||
const selectors = [
|
||||
'html[data-role-name="RewardsPortal"]',
|
||||
'html[data-role-name*="RewardsPortal"]',
|
||||
'body[data-role-name*="RewardsPortal"]',
|
||||
'[data-role-name*="RewardsPortal"]',
|
||||
'[data-bi-name="rewards-dashboard"]',
|
||||
'main[data-bi-name="dashboard"]',
|
||||
'#more-activities',
|
||||
'#dashboard'
|
||||
]
|
||||
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
for (const sel of selectors) {
|
||||
const loc = page.locator(sel).first()
|
||||
if (await loc.isVisible().catch(() => false)) {
|
||||
return sel
|
||||
}
|
||||
}
|
||||
await this.bot.utils.wait(350)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// --------------- Verification / State ---------------
|
||||
private async awaitRewardsPortal(page: Page) {
|
||||
const start = Date.now()
|
||||
while (Date.now() - start < DEFAULT_TIMEOUTS.loginMaxMs) {
|
||||
await this.handlePasskeyPrompts(page, 'main')
|
||||
const u = new URL(page.url())
|
||||
const isRewardsHost = u.hostname === LOGIN_TARGET.host
|
||||
const isKnownPath = u.pathname === LOGIN_TARGET.path
|
||||
|| u.pathname === '/dashboard'
|
||||
|| u.pathname === '/rewardsapp/dashboard'
|
||||
|| u.pathname.startsWith('/?')
|
||||
if (isRewardsHost && isKnownPath) break
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
|
||||
const portalSelector = await this.waitForRewardsRoot(page, 8000)
|
||||
if (!portalSelector) {
|
||||
try {
|
||||
await this.bot.browser.func.goHome(page)
|
||||
} catch {/* ignore fallback errors */ }
|
||||
|
||||
const fallbackSelector = await this.waitForRewardsRoot(page, 6000)
|
||||
if (!fallbackSelector) {
|
||||
throw this.bot.log(this.bot.isMobile, 'LOGIN', 'Portal root element missing after navigation', 'error')
|
||||
}
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', `Reached rewards portal via fallback (${fallbackSelector})`)
|
||||
return
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', `Reached rewards portal (${portalSelector})`)
|
||||
}
|
||||
|
||||
private async verifyBingContext(page: Page) {
|
||||
try {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing auth context')
|
||||
await page.goto('https://www.bing.com/fd/auth/signin?action=interactive&provider=windows_live_id&return_url=https%3A%2F%2Fwww.bing.com%2F')
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const u = new URL(page.url())
|
||||
if (u.hostname === 'www.bing.com' && u.pathname === '/') {
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
const ok = await page.waitForSelector('#id_n', { timeout: 3000 }).then(() => true).catch(() => false)
|
||||
if (ok || this.bot.isMobile) { this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing verification passed'); break }
|
||||
}
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
} catch (e) {
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-BING', 'Bing verification error: ' + e, 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
private async checkAccountLocked(page: Page) {
|
||||
const locked = await page.waitForSelector('#serviceAbuseLandingTitle', { timeout: 1200 }).then(() => true).catch(() => false)
|
||||
if (locked) throw this.bot.log(this.bot.isMobile, 'CHECK-LOCKED', 'Account locked by Microsoft (serviceAbuseLandingTitle)', 'error')
|
||||
}
|
||||
|
||||
// --------------- Passkey / Dialog Handling ---------------
|
||||
private async handlePasskeyPrompts(page: Page, context: 'main' | 'oauth') {
|
||||
let did = false
|
||||
// Video heuristic
|
||||
const biometric = await page.waitForSelector(SELECTORS.biometricVideo, { timeout: 500 }).catch(() => null)
|
||||
if (biometric) {
|
||||
const btn = await page.$(SELECTORS.passkeySecondary)
|
||||
if (btn) { await btn.click().catch(() => { }); did = true; this.logPasskeyOnce('video heuristic') }
|
||||
}
|
||||
if (!did) {
|
||||
const titleEl = await page.waitForSelector(SELECTORS.passkeyTitle, { timeout: 500 }).catch(() => null)
|
||||
const secBtn = await page.waitForSelector(SELECTORS.passkeySecondary, { timeout: 500 }).catch(() => null)
|
||||
const primBtn = await page.waitForSelector(SELECTORS.passkeyPrimary, { timeout: 500 }).catch(() => null)
|
||||
const title = (titleEl ? (await titleEl.textContent()) : '')?.trim() || ''
|
||||
const looksLike = /sign in faster|passkey|fingerprint|face|pin/i.test(title)
|
||||
if (looksLike && secBtn) { await secBtn.click().catch(() => { }); did = true; this.logPasskeyOnce('title heuristic ' + title) }
|
||||
else if (!did && secBtn && primBtn) {
|
||||
const text = (await secBtn.textContent() || '').trim()
|
||||
if (/skip for now/i.test(text)) { await secBtn.click().catch(() => { }); did = true; this.logPasskeyOnce('secondary button text') }
|
||||
}
|
||||
if (!did) {
|
||||
const textBtn = await page.locator('xpath=//button[contains(normalize-space(.),"Skip for now")]').first()
|
||||
if (await textBtn.isVisible().catch(() => false)) { await textBtn.click().catch(() => { }); did = true; this.logPasskeyOnce('text fallback') }
|
||||
}
|
||||
if (!did) {
|
||||
const close = await page.$('#close-button')
|
||||
if (close) { await close.click().catch(() => { }); did = true; this.logPasskeyOnce('close button') }
|
||||
}
|
||||
}
|
||||
|
||||
// KMSI prompt
|
||||
const kmsi = await page.waitForSelector(SELECTORS.kmsiVideo, { timeout: 400 }).catch(() => null)
|
||||
if (kmsi) {
|
||||
const yes = await page.$(SELECTORS.passkeyPrimary)
|
||||
if (yes) { await yes.click().catch(() => { }); did = true; this.bot.log(this.bot.isMobile, 'LOGIN-KMSI', 'Accepted KMSI prompt') }
|
||||
}
|
||||
|
||||
if (!did && context === 'main') {
|
||||
this.noPromptIterations++
|
||||
const now = Date.now()
|
||||
if (this.noPromptIterations === 1 || now - this.lastNoPromptLog > 10000) {
|
||||
this.lastNoPromptLog = now
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-NO-PROMPT', `No dialogs (x${this.noPromptIterations})`)
|
||||
if (this.noPromptIterations > 50) this.noPromptIterations = 0
|
||||
}
|
||||
} else if (did) {
|
||||
this.noPromptIterations = 0
|
||||
}
|
||||
}
|
||||
|
||||
private logPasskeyOnce(reason: string) {
|
||||
if (this.passkeyHandled) return
|
||||
this.passkeyHandled = true
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-PASSKEY', `Dismissed passkey prompt (${reason})`)
|
||||
}
|
||||
|
||||
// --------------- Security Detection ---------------
|
||||
private async detectSignInBlocked(page: Page): Promise<boolean> {
|
||||
if (this.bot.compromisedModeActive && this.bot.compromisedReason === 'sign-in-blocked') return true
|
||||
try {
|
||||
let text = ''
|
||||
for (const sel of ['[data-testid="title"]', 'h1', 'div[role="heading"]', 'div.text-title']) {
|
||||
const el = await page.waitForSelector(sel, { timeout: 600 }).catch(() => null)
|
||||
if (el) {
|
||||
const t = (await el.textContent() || '').trim()
|
||||
if (t && t.length < 300) text += ' ' + t
|
||||
}
|
||||
}
|
||||
const lower = text.toLowerCase()
|
||||
let matched: string | null = null
|
||||
for (const p of SIGN_IN_BLOCK_PATTERNS) { if (p.re.test(lower)) { matched = p.label; break } }
|
||||
if (!matched) return false
|
||||
const email = this.bot.currentAccountEmail || 'unknown'
|
||||
const incident: SecurityIncident = {
|
||||
kind: 'We can\'t sign you in (blocked)',
|
||||
account: email,
|
||||
details: [matched ? `Pattern: ${matched}` : 'Pattern: unknown'],
|
||||
next: ['Manual recovery required before continuing']
|
||||
}
|
||||
await this.sendIncidentAlert(incident, 'warn')
|
||||
this.bot.compromisedModeActive = true
|
||||
this.bot.compromisedReason = 'sign-in-blocked'
|
||||
this.startCompromisedInterval()
|
||||
await this.bot.engageGlobalStandby('sign-in-blocked', email).catch(() => { })
|
||||
return true
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
private async tryRecoveryMismatchCheck(page: Page, email: string) { try { await this.detectAndHandleRecoveryMismatch(page, email) } catch {/* ignore */ } }
|
||||
private async detectAndHandleRecoveryMismatch(page: Page, email: string) {
|
||||
try {
|
||||
const recoveryEmail: string | undefined = this.bot.currentAccountRecoveryEmail
|
||||
if (!recoveryEmail || !/@/.test(recoveryEmail)) return
|
||||
const accountEmail = email
|
||||
const parseRef = (val: string) => { const [l, d] = val.split('@'); return { local: l || '', domain: (d || '').toLowerCase(), prefix2: (l || '').slice(0, 2).toLowerCase() } }
|
||||
const refs = [parseRef(recoveryEmail), parseRef(accountEmail)].filter(r => r.domain && r.prefix2)
|
||||
if (refs.length === 0) return
|
||||
|
||||
const candidates: string[] = []
|
||||
// Direct selectors (Microsoft variants + French spans)
|
||||
const sel = '[data-testid="recoveryEmailHint"], #recoveryEmail, [id*="ProofEmail"], [id*="EmailProof"], [data-testid*="Email"], span:has(span.fui-Text)'
|
||||
const el = await page.waitForSelector(sel, { timeout: 1500 }).catch(() => null)
|
||||
if (el) { const t = (await el.textContent() || '').trim(); if (t) candidates.push(t) }
|
||||
|
||||
// List items
|
||||
const li = page.locator('[role="listitem"], li')
|
||||
const liCount = await li.count().catch(() => 0)
|
||||
for (let i = 0; i < liCount && i < 12; i++) { const t = (await li.nth(i).textContent().catch(() => ''))?.trim() || ''; if (t && /@/.test(t)) candidates.push(t) }
|
||||
|
||||
// XPath generic masked patterns
|
||||
const xp = page.locator('xpath=//*[contains(normalize-space(.), "@") and (contains(normalize-space(.), "*") or contains(normalize-space(.), "•"))]')
|
||||
const xpCount = await xp.count().catch(() => 0)
|
||||
for (let i = 0; i < xpCount && i < 12; i++) { const t = (await xp.nth(i).textContent().catch(() => ''))?.trim() || ''; if (t && t.length < 300) candidates.push(t) }
|
||||
|
||||
// Normalize
|
||||
const seen = new Set<string>()
|
||||
const norm = (s: string) => s.replace(/\s+/g, ' ').trim()
|
||||
const uniq = candidates.map(norm).filter(t => t && !seen.has(t) && seen.add(t))
|
||||
// Masked filter
|
||||
let masked = uniq.filter(t => /@/.test(t) && /[*•]/.test(t))
|
||||
|
||||
if (masked.length === 0) {
|
||||
// Fallback full HTML scan
|
||||
try {
|
||||
await page.waitForSelector('#i0118', { visible: true, timeout: 2000 })
|
||||
await this.bot.utils.wait(2000)
|
||||
const html = await page.content()
|
||||
const generic = /[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+/g
|
||||
const frPhrase = /Nous\s+enverrons\s+un\s+code\s+à\s+([^<@]*[A-Za-z0-9]{1,4}[*•]{2,}[A-Za-z0-9*•._-]*@[A-Za-z0-9.-]+)[^.]{0,120}?Pour\s+vérifier/gi
|
||||
const found = new Set<string>()
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = generic.exec(html)) !== null) found.add(m[0])
|
||||
while ((m = frPhrase.exec(html)) !== null) { const raw = m[1]?.replace(/<[^>]+>/g, '').trim(); if (raw) found.add(raw) }
|
||||
if (found.size > 0) masked = Array.from(found)
|
||||
} catch {/* ignore */ }
|
||||
}
|
||||
if (masked.length === 0) return
|
||||
|
||||
await page.type('#i0118', password)
|
||||
await page.click('#idSIButton9')
|
||||
// Prefer one mentioning email/adresse
|
||||
const preferred = masked.find(t => /email|courriel|adresse|mail/i.test(t)) || masked[0]!
|
||||
// Extract the masked email: Microsoft sometimes shows only first 1 char (k*****@domain) or 2 chars (ko*****@domain).
|
||||
// We ONLY compare (1 or 2) leading visible alphanumeric chars + full domain (case-insensitive).
|
||||
// This avoids false positives when the displayed mask hides the 2nd char.
|
||||
const maskRegex = /([a-zA-Z0-9]{1,2})[a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/
|
||||
const m = maskRegex.exec(preferred)
|
||||
// Fallback: try to salvage with looser pattern if first regex fails
|
||||
const loose = !m ? /([a-zA-Z0-9])[*•][a-zA-Z0-9*•._-]*@([a-zA-Z0-9.-]+\.[a-zA-Z]{2,})/.exec(preferred) : null
|
||||
const use = m || loose
|
||||
const extracted = use ? use[0] : preferred
|
||||
const extractedLower = extracted.toLowerCase()
|
||||
let observedPrefix = ((use && use[1]) ? use[1] : '').toLowerCase()
|
||||
let observedDomain = ((use && use[2]) ? use[2] : '').toLowerCase()
|
||||
if (!observedDomain && extractedLower.includes('@')) {
|
||||
const parts = extractedLower.split('@')
|
||||
observedDomain = parts[1] || ''
|
||||
}
|
||||
if (!observedPrefix && extractedLower.includes('@')) {
|
||||
const parts = extractedLower.split('@')
|
||||
observedPrefix = (parts[0] || '').replace(/[^a-z0-9]/gi, '').slice(0, 2)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log('LOGIN', '2FA code required')
|
||||
|
||||
const code = await new Promise<string>((resolve) => {
|
||||
rl.question('Enter 2FA code:\n', (input) => {
|
||||
rl.close()
|
||||
resolve(input)
|
||||
})
|
||||
})
|
||||
|
||||
await page.type('input[name="otc"]', code)
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
} finally {
|
||||
this.bot.log('LOGIN', 'Password entered successfully')
|
||||
// Determine if any reference (recoveryEmail or accountEmail) matches observed mask logic
|
||||
const matchRef = refs.find(r => {
|
||||
if (r.domain !== observedDomain) return false
|
||||
// If only one char visible, only enforce first char; if two, enforce both.
|
||||
if (observedPrefix.length === 1) {
|
||||
return r.prefix2.startsWith(observedPrefix)
|
||||
}
|
||||
return r.prefix2 === observedPrefix
|
||||
})
|
||||
|
||||
const currentURL = new URL(page.url())
|
||||
|
||||
while (currentURL.pathname !== '/' || currentURL.hostname !== 'account.microsoft.com') {
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
currentURL.href = page.url()
|
||||
if (!matchRef) {
|
||||
const incident: SecurityIncident = {
|
||||
kind: 'Recovery email mismatch',
|
||||
account: email,
|
||||
details: [
|
||||
`MaskedShown: ${preferred}`,
|
||||
`Extracted: ${extracted}`,
|
||||
`Observed => ${observedPrefix || '??'}**@${observedDomain || '??'}`,
|
||||
`Expected => ${refs.map(r => `${r.prefix2}**@${r.domain}`).join(' OR ')}`
|
||||
],
|
||||
next: [
|
||||
'Automation halted globally (standby engaged).',
|
||||
'Verify account security & recovery email in Microsoft settings.',
|
||||
'Update accounts.json if the change was legitimate before restart.'
|
||||
]
|
||||
}
|
||||
await this.sendIncidentAlert(incident, 'critical')
|
||||
this.bot.compromisedModeActive = true
|
||||
this.bot.compromisedReason = 'recovery-mismatch'
|
||||
this.startCompromisedInterval()
|
||||
await this.bot.engageGlobalStandby('recovery-mismatch', email).catch(() => { })
|
||||
} else {
|
||||
const mode = observedPrefix.length === 1 ? 'lenient' : 'strict'
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN-RECOVERY', `Recovery OK (${mode}): ${extracted} matches ${matchRef.prefix2}**@${matchRef.domain}`)
|
||||
}
|
||||
} catch {/* non-fatal */ }
|
||||
}
|
||||
|
||||
// Wait for login to complete
|
||||
await page.waitForSelector('html[data-role-name="MeePortal"]', { timeout: 10_000 })
|
||||
}
|
||||
private async switchToPasswordLink(page: Page) {
|
||||
try {
|
||||
const link = await page.locator('xpath=//span[@role="button" and (contains(translate(normalize-space(.),"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"use your password") or contains(translate(normalize-space(.),"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"utilisez votre mot de passe"))]').first()
|
||||
if (await link.isVisible().catch(() => false)) {
|
||||
await link.click().catch(() => { })
|
||||
await this.bot.utils.wait(800)
|
||||
this.bot.log(this.bot.isMobile, 'LOGIN', 'Clicked "Use your password" link')
|
||||
}
|
||||
} catch {/* ignore */ }
|
||||
}
|
||||
|
||||
private async checkBingLogin(page: Page): Promise<void> {
|
||||
try {
|
||||
this.bot.log('LOGIN-BING', 'Verifying Bing login')
|
||||
await page.goto('https://www.bing.com/fd/auth/signin?action=interactive&provider=windows_live_id&return_url=https%3A%2F%2Fwww.bing.com%2F')
|
||||
// --------------- Incident Helpers ---------------
|
||||
private async sendIncidentAlert(incident: SecurityIncident, severity: 'warn' | 'critical' = 'warn') {
|
||||
const lines = [`[Incident] ${incident.kind}`, `Account: ${incident.account}`]
|
||||
if (incident.details?.length) lines.push(`Details: ${incident.details.join(' | ')}`)
|
||||
if (incident.next?.length) lines.push(`Next: ${incident.next.join(' -> ')}`)
|
||||
if (incident.docsUrl) lines.push(`Docs: ${incident.docsUrl}`)
|
||||
const level: 'warn' | 'error' = severity === 'critical' ? 'error' : 'warn'
|
||||
this.bot.log(this.bot.isMobile, 'SECURITY', lines.join(' | '), level)
|
||||
try {
|
||||
const { ConclusionWebhook } = await import('../util/ConclusionWebhook')
|
||||
const fields = [
|
||||
{ name: 'Account', value: incident.account },
|
||||
...(incident.details?.length ? [{ name: 'Details', value: incident.details.join('\n') }] : []),
|
||||
...(incident.next?.length ? [{ name: 'Next steps', value: incident.next.join('\n') }] : []),
|
||||
...(incident.docsUrl ? [{ name: 'Docs', value: incident.docsUrl }] : [])
|
||||
]
|
||||
await ConclusionWebhook(
|
||||
this.bot.config,
|
||||
`🔐 ${incident.kind}`,
|
||||
'_Security check',
|
||||
fields,
|
||||
severity === 'critical' ? 0xFF0000 : 0xFFAA00
|
||||
)
|
||||
} catch {/* ignore */ }
|
||||
}
|
||||
|
||||
const maxIterations = 5
|
||||
private startCompromisedInterval() {
|
||||
if (this.compromisedInterval) clearInterval(this.compromisedInterval)
|
||||
this.compromisedInterval = setInterval(() => {
|
||||
try { this.bot.log(this.bot.isMobile, 'SECURITY', 'Account in security standby. Review before proceeding.', 'warn') } catch {/* ignore */ }
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
for (let iteration = 1; iteration <= maxIterations; iteration++) {
|
||||
const currentUrl = new URL(page.url())
|
||||
|
||||
if (currentUrl.hostname === 'www.bing.com' && currentUrl.pathname === '/') {
|
||||
await this.bot.utils.wait(3000)
|
||||
await this.bot.browser.utils.tryDismissBingCookieBanner(page)
|
||||
|
||||
const loggedIn = await this.checkBingLoginStatus(page)
|
||||
if (loggedIn) {
|
||||
this.bot.log('LOGIN-BING', 'Bing login verification passed!')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log('LOGIN-BING', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
private async checkBingLoginStatus(page: Page): Promise<boolean> {
|
||||
try {
|
||||
await page.waitForSelector('#id_n', { timeout: 10_000 })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
// --------------- Infrastructure ---------------
|
||||
private async disableFido(page: Page) {
|
||||
await page.route('**/GetCredentialType.srf*', route => {
|
||||
try {
|
||||
const body = JSON.parse(route.request().postData() || '{}')
|
||||
body.isFidoSupported = false
|
||||
route.continue({ postData: JSON.stringify(body) })
|
||||
} catch { route.continue() }
|
||||
}).catch(() => { })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,66 +1,117 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
|
||||
import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import JobState from '../util/JobState'
|
||||
import Retry from '../util/Retry'
|
||||
import { AdaptiveThrottler } from '../util/AdaptiveThrottler'
|
||||
|
||||
export class Workers {
|
||||
public bot: MicrosoftRewardsBot
|
||||
private jobState: JobState
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
this.jobState = new JobState(this.bot.config)
|
||||
}
|
||||
|
||||
// Daily Set
|
||||
async doDailySet(page: Page, data: DashboardData) {
|
||||
const todayData = data.dailySetPromotions[this.bot.utils.getFormattedDate()]
|
||||
|
||||
const activitiesUncompleted = todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? []
|
||||
const today = this.bot.utils.getFormattedDate()
|
||||
const activitiesUncompleted = (todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? [])
|
||||
.filter(x => {
|
||||
if (this.bot.config.jobState?.enabled === false) return true
|
||||
const email = this.bot.currentAccountEmail || 'unknown'
|
||||
return !this.jobState.isDone(email, today, x.offerId)
|
||||
})
|
||||
|
||||
if (!activitiesUncompleted.length) {
|
||||
this.bot.log('DAILY-SET', 'All Daily Set" items have already been completed')
|
||||
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'All Daily Set" items have already been completed')
|
||||
return
|
||||
}
|
||||
|
||||
// Solve Activities
|
||||
this.bot.log('DAILY-SET', 'Started solving "Daily Set" items')
|
||||
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'Started solving "Daily Set" items')
|
||||
|
||||
await this.solveActivities(page, activitiesUncompleted)
|
||||
|
||||
this.bot.log('DAILY-SET', 'All "Daily Set" items have been completed')
|
||||
// Mark as done to prevent duplicate work if checkpoints enabled
|
||||
if (this.bot.config.jobState?.enabled !== false) {
|
||||
const email = this.bot.currentAccountEmail || 'unknown'
|
||||
for (const a of activitiesUncompleted) {
|
||||
this.jobState.markDone(email, today, a.offerId)
|
||||
}
|
||||
}
|
||||
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
// Always return to the homepage if not already
|
||||
await this.bot.browser.func.goHome(page)
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have been completed')
|
||||
|
||||
// Optional: immediately run desktop search bundle
|
||||
if (!this.bot.isMobile && this.bot.config.workers.bundleDailySetWithSearch && this.bot.config.workers.doDesktopSearch) {
|
||||
try {
|
||||
await this.bot.utils.waitRandom(1200, 2600)
|
||||
await this.bot.activities.doSearch(page, data)
|
||||
} catch (e) {
|
||||
this.bot.log(this.bot.isMobile, 'DAILY-SET', `Post-DailySet search failed: ${e instanceof Error ? e.message : e}`, 'warn')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Punch Card
|
||||
async doPunchCard(page: Page, data: DashboardData) {
|
||||
|
||||
const punchCardsUncompleted = data.punchCards?.filter(x => !x.parentPromotion.complete) ?? [] // Only return uncompleted punch cards
|
||||
const punchCardsUncompleted = data.punchCards?.filter(x => x.parentPromotion && !x.parentPromotion.complete) ?? [] // Only return uncompleted punch cards
|
||||
|
||||
if (!punchCardsUncompleted.length) {
|
||||
this.bot.log('PUNCH-CARD', 'All "Punch Cards" have already been completed')
|
||||
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', 'All "Punch Cards" have already been completed')
|
||||
return
|
||||
}
|
||||
|
||||
for (const punchCard of punchCardsUncompleted) {
|
||||
|
||||
// Ensure parentPromotion exists before proceeding
|
||||
if (!punchCard.parentPromotion?.title) {
|
||||
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `Skipped punchcard "${punchCard.name}" | Reason: Parent promotion is missing!`, 'warn')
|
||||
continue
|
||||
}
|
||||
|
||||
// Get latest page for each card
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
const activitiesUncompleted = punchCard.childPromotions.filter(x => !x.complete) // Only return uncompleted activities
|
||||
|
||||
// Solve Activities
|
||||
this.bot.log('PUNCH-CARD', `Started solving "Punch Card" items for punchcard: "${punchCard.parentPromotion.title}"`)
|
||||
|
||||
const browser = page.browser()
|
||||
page = await browser.newPage()
|
||||
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `Started solving "Punch Card" items for punchcard: "${punchCard.parentPromotion.title}"`)
|
||||
|
||||
// Got to punch card index page in a new tab
|
||||
await page.goto(punchCard.parentPromotion.destinationUrl, { referer: this.bot.config.baseURL })
|
||||
|
||||
// Wait for new page to load, max 10 seconds, however try regardless in case of error
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { })
|
||||
|
||||
await this.solveActivities(page, activitiesUncompleted, punchCard)
|
||||
|
||||
// Close the punch card index page
|
||||
await page.close()
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
this.bot.log('PUNCH-CARD', `All items for punchcard: "${punchCard.parentPromotion.title}" have been completed`)
|
||||
const pages = page.context().pages()
|
||||
|
||||
if (pages.length > 3) {
|
||||
await page.close()
|
||||
} else {
|
||||
await this.bot.browser.func.goHome(page)
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', `All items for punchcard: "${punchCard.parentPromotion.title}" have been completed`)
|
||||
}
|
||||
|
||||
this.bot.log('PUNCH-CARD', 'All "Punch Card" items have been completed')
|
||||
this.bot.log(this.bot.isMobile, 'PUNCH-CARD', 'All "Punch Card" items have been completed')
|
||||
}
|
||||
|
||||
// More Promotions
|
||||
@@ -72,105 +123,120 @@ export class Workers {
|
||||
morePromotions.push(data.promotionalItem as unknown as MorePromotion)
|
||||
}
|
||||
|
||||
const activitiesUncompleted = morePromotions?.filter(x => !x.complete && x.pointProgressMax > 0) ?? []
|
||||
const activitiesUncompleted = morePromotions?.filter(x => !x.complete && x.pointProgressMax > 0 && x.exclusiveLockedFeatureStatus !== 'locked') ?? []
|
||||
|
||||
if (!activitiesUncompleted.length) {
|
||||
this.bot.log('MORE-PROMOTIONS', 'All "More Promotion" items have already been completed')
|
||||
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have already been completed')
|
||||
return
|
||||
}
|
||||
|
||||
// Solve Activities
|
||||
this.bot.log('MORE-PROMOTIONS', 'Started solving "More Promotions" item')
|
||||
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'Started solving "More Promotions" items')
|
||||
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
await this.solveActivities(page, activitiesUncompleted)
|
||||
|
||||
this.bot.log('MORE-PROMOTIONS', 'All "More Promotion" items have been completed')
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
// Always return to the homepage if not already
|
||||
await this.bot.browser.func.goHome(page)
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have been completed')
|
||||
}
|
||||
|
||||
// Solve all the different types of activities
|
||||
private async solveActivities(page: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) {
|
||||
private async solveActivities(activityPage: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) {
|
||||
const activityInitial = activityPage.url()
|
||||
const retry = new Retry(this.bot.config.retryPolicy)
|
||||
const throttle = new AdaptiveThrottler()
|
||||
|
||||
for (const activity of activities) {
|
||||
try {
|
||||
activityPage = await this.manageTabLifecycle(activityPage, activityInitial)
|
||||
await this.applyThrottle(throttle, 800, 1400)
|
||||
|
||||
let selector = `[data-bi-id="${activity.offerId}"]`
|
||||
const selector = await this.buildActivitySelector(activityPage, activity, punchCard)
|
||||
await this.prepareActivityPage(activityPage, selector, throttle)
|
||||
|
||||
if (punchCard) {
|
||||
selector = await this.bot.browser.func.getPunchCardActivity(page, activity)
|
||||
|
||||
} else if (activity.name.toLowerCase().includes('membercenter')) {
|
||||
|
||||
// Promotion
|
||||
if (activity.priority === 1) {
|
||||
selector = '#promo-item'
|
||||
} else {
|
||||
selector = `[data-bi-id="${activity.name}"]`
|
||||
}
|
||||
const typeLabel = this.bot.activities.getTypeLabel(activity)
|
||||
if (typeLabel !== 'Unsupported') {
|
||||
await this.executeActivity(activityPage, activity, selector, throttle, retry)
|
||||
} else {
|
||||
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Skipped activity "${activity.title}" | Reason: Unsupported type: "${activity.promotionType}"!`, 'warn')
|
||||
}
|
||||
|
||||
// Wait for element to load
|
||||
await page.waitForSelector(selector, { timeout: 10_000 })
|
||||
|
||||
// Click element, it will be opened in a new tab
|
||||
await page.click(selector)
|
||||
|
||||
// Cooldown
|
||||
await this.bot.utils.wait(4000)
|
||||
|
||||
// Select the new activity page
|
||||
const activityPage = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
// Wait for body to load
|
||||
await activityPage.waitForSelector('body', { timeout: 10_000 })
|
||||
|
||||
switch (activity.promotionType) {
|
||||
// Quiz (Poll, Quiz or ABC)
|
||||
case 'quiz':
|
||||
switch (activity.pointProgressMax) {
|
||||
// Poll or ABC (Usually 10 points)
|
||||
case 10:
|
||||
// Normal poll
|
||||
if (activity.destinationUrl.toLowerCase().includes('pollscenarioid')) {
|
||||
this.bot.log('ACTIVITY', `Found activity type: "Poll" title: "${activity.title}"`)
|
||||
await this.bot.activities.doPoll(activityPage)
|
||||
} else { // ABC
|
||||
this.bot.log('ACTIVITY', `Found activity type: "ABC" title: "${activity.title}"`)
|
||||
await this.bot.activities.doABC(activityPage)
|
||||
}
|
||||
break
|
||||
|
||||
// This Or That Quiz (Usually 50 points)
|
||||
case 50:
|
||||
this.bot.log('ACTIVITY', `Found activity type: "ThisOrThat" title: "${activity.title}"`)
|
||||
await this.bot.activities.doThisOrThat(activityPage)
|
||||
break
|
||||
|
||||
// Quizzes are usually 30-40 points
|
||||
default:
|
||||
this.bot.log('ACTIVITY', `Found activity type: "Quiz" title: "${activity.title}"`)
|
||||
await this.bot.activities.doQuiz(activityPage)
|
||||
break
|
||||
}
|
||||
break
|
||||
|
||||
// UrlReward (Visit)
|
||||
case 'urlreward':
|
||||
this.bot.log('ACTIVITY', `Found activity type: "UrlReward" title: "${activity.title}"`)
|
||||
await this.bot.activities.doUrlReward(activityPage)
|
||||
break
|
||||
|
||||
// Misc, Usually UrlReward Type
|
||||
default:
|
||||
this.bot.log('ACTIVITY', `Found activity type: "Misc" title: "${activity.title}"`)
|
||||
await this.bot.activities.doUrlReward(activityPage)
|
||||
break
|
||||
}
|
||||
|
||||
// Cooldown
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.applyThrottle(throttle, 1200, 2600)
|
||||
} catch (error) {
|
||||
this.bot.log('ACTIVITY', 'An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'ACTIVITY', 'An error occurred:' + error, 'error')
|
||||
throttle.record(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async manageTabLifecycle(page: Page, initialUrl: string): Promise<Page> {
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
const pages = page.context().pages()
|
||||
if (pages.length > 3) {
|
||||
await page.close()
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
}
|
||||
|
||||
if (page.url() !== initialUrl) {
|
||||
await page.goto(initialUrl)
|
||||
}
|
||||
|
||||
return page
|
||||
}
|
||||
|
||||
private async buildActivitySelector(page: Page, activity: PromotionalItem | MorePromotion, punchCard?: PunchCard): Promise<string> {
|
||||
if (punchCard) {
|
||||
return await this.bot.browser.func.getPunchCardActivity(page, activity)
|
||||
}
|
||||
|
||||
const name = activity.name.toLowerCase()
|
||||
if (name.includes('membercenter') || name.includes('exploreonbing')) {
|
||||
return `[data-bi-id^="${activity.name}"] .pointLink:not(.contentContainer .pointLink)`
|
||||
}
|
||||
|
||||
return `[data-bi-id^="${activity.offerId}"] .pointLink:not(.contentContainer .pointLink)`
|
||||
}
|
||||
|
||||
private async prepareActivityPage(page: Page, selector: string, throttle: AdaptiveThrottler): Promise<void> {
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
|
||||
await this.bot.browser.utils.humanizePage(page)
|
||||
await this.applyThrottle(throttle, 1200, 2600)
|
||||
}
|
||||
|
||||
private async executeActivity(page: Page, activity: PromotionalItem | MorePromotion, selector: string, throttle: AdaptiveThrottler, retry: Retry): Promise<void> {
|
||||
this.bot.log(this.bot.isMobile, 'ACTIVITY', `Found activity type: "${this.bot.activities.getTypeLabel(activity)}" title: "${activity.title}"`)
|
||||
|
||||
await page.click(selector)
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
const timeoutMs = this.bot.utils.stringToMs(this.bot.config?.globalTimeout ?? '30s') * 2
|
||||
const runWithTimeout = (p: Promise<void>) => Promise.race([
|
||||
p,
|
||||
new Promise<void>((_, rej) => setTimeout(() => rej(new Error('activity-timeout')), timeoutMs))
|
||||
])
|
||||
|
||||
await retry.run(async () => {
|
||||
try {
|
||||
await runWithTimeout(this.bot.activities.run(page, activity))
|
||||
throttle.record(true)
|
||||
} catch (e) {
|
||||
throttle.record(false)
|
||||
throw e
|
||||
}
|
||||
}, () => true)
|
||||
|
||||
await this.bot.browser.utils.humanizePage(page)
|
||||
}
|
||||
|
||||
private async applyThrottle(throttle: AdaptiveThrottler, min: number, max: number): Promise<void> {
|
||||
const multiplier = throttle.getDelayMultiplier()
|
||||
await this.bot.utils.waitRandom(Math.floor(min * multiplier), Math.floor(max * multiplier))
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,51 +1,50 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
import { RETRY_LIMITS, TIMEOUTS } from '../../constants'
|
||||
|
||||
|
||||
export class ABC extends Workers {
|
||||
|
||||
async doABC(page: Page) {
|
||||
this.bot.log('ABC', 'Trying to complete poll')
|
||||
this.bot.log(this.bot.isMobile, 'ABC', 'Trying to complete poll')
|
||||
|
||||
try {
|
||||
let $ = await this.bot.browser.func.refreshCheerio(page)
|
||||
let $ = await this.bot.browser.func.loadInCheerio(page)
|
||||
|
||||
// Don't loop more than 15 in case unable to solve, would lock otherwise
|
||||
const maxIterations = 15
|
||||
let i
|
||||
for (i = 0; i < maxIterations && !$('span.rw_icon').length; i++) {
|
||||
await page.waitForSelector('.wk_OptionClickClass', { visible: true, timeout: 10_000 })
|
||||
for (i = 0; i < RETRY_LIMITS.ABC_MAX && !$('span.rw_icon').length; i++) {
|
||||
await page.waitForSelector('.wk_OptionClickClass', { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
|
||||
|
||||
const answers = $('.wk_OptionClickClass')
|
||||
const answer = answers[this.bot.utils.randomNumber(0, 2)]?.attribs['id']
|
||||
|
||||
await page.waitForSelector(`#${answer}`, { visible: true, timeout: 10_000 })
|
||||
await page.waitForSelector(`#${answer}`, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
|
||||
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
|
||||
await page.click(`#${answer}`) // Click answer
|
||||
|
||||
await this.bot.utils.wait(4000)
|
||||
await page.waitForSelector('div.wk_button', { visible: true, timeout: 10_000 })
|
||||
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
|
||||
await page.waitForSelector('div.wk_button', { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT })
|
||||
await page.click('div.wk_button') // Click next question button
|
||||
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
$ = await this.bot.browser.func.refreshCheerio(page)
|
||||
await this.bot.utils.wait(1000)
|
||||
$ = await this.bot.browser.func.loadInCheerio(page)
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(4000)
|
||||
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
|
||||
await page.close()
|
||||
|
||||
if (i === maxIterations) {
|
||||
this.bot.log('ABC', 'Failed to solve quiz, exceeded max iterations of 15', 'warn')
|
||||
if (i === RETRY_LIMITS.ABC_MAX) {
|
||||
this.bot.log(this.bot.isMobile, 'ABC', `Failed to solve quiz, exceeded max iterations of ${RETRY_LIMITS.ABC_MAX}`, 'warn')
|
||||
} else {
|
||||
this.bot.log('ABC', 'Completed the ABC successfully')
|
||||
this.bot.log(this.bot.isMobile, 'ABC', 'Completed the ABC successfully')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
await page.close()
|
||||
this.bot.log('ABC', 'An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'ABC', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
49
src/functions/activities/DailyCheckIn.ts
Normal file
49
src/functions/activities/DailyCheckIn.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
import { DashboardData } from '../../interface/DashboardData'
|
||||
|
||||
|
||||
export class DailyCheckIn extends Workers {
|
||||
public async doDailyCheckIn(accessToken: string, data: DashboardData) {
|
||||
this.bot.log(this.bot.isMobile, 'DAILY-CHECK-IN', 'Starting Daily Check In')
|
||||
|
||||
try {
|
||||
let geoLocale = data.userProfile.attributes.country
|
||||
geoLocale = (this.bot.config.searchSettings.useGeoLocaleQueries && geoLocale.length === 2) ? geoLocale.toLowerCase() : 'us'
|
||||
|
||||
const jsonData = {
|
||||
amount: 1,
|
||||
country: geoLocale,
|
||||
id: randomBytes(64).toString('hex'),
|
||||
type: 101,
|
||||
attributes: {
|
||||
offerid: 'Gamification_Sapphire_DailyCheckIn'
|
||||
}
|
||||
}
|
||||
|
||||
const claimRequest: AxiosRequestConfig = {
|
||||
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Rewards-Country': geoLocale,
|
||||
'X-Rewards-Language': 'en',
|
||||
'X-Rewards-ismobile': 'true'
|
||||
},
|
||||
data: JSON.stringify(jsonData)
|
||||
}
|
||||
|
||||
const claimResponse = await this.bot.axios.request(claimRequest)
|
||||
const claimedPoint = parseInt((await claimResponse.data).response?.activity?.p) ?? 0
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'DAILY-CHECK-IN', claimedPoint > 0 ? `Claimed ${claimedPoint} points` : 'Already claimed today')
|
||||
} catch (error) {
|
||||
this.bot.log(this.bot.isMobile, 'DAILY-CHECK-IN', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,28 +1,31 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
import { TIMEOUTS } from '../../constants'
|
||||
|
||||
|
||||
export class Poll extends Workers {
|
||||
|
||||
async doPoll(page: Page) {
|
||||
this.bot.log('POLL', 'Trying to complete poll')
|
||||
this.bot.log(this.bot.isMobile, 'POLL', 'Trying to complete poll')
|
||||
|
||||
try {
|
||||
const buttonId = `#btoption${Math.floor(this.bot.utils.randomNumber(0, 1))}`
|
||||
|
||||
await page.waitForSelector(buttonId, { visible: true, timeout: 10_000 })
|
||||
await this.bot.utils.wait(2000)
|
||||
await page.waitForSelector(buttonId, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }).catch((e) => {
|
||||
this.bot.log(this.bot.isMobile, 'POLL', `Could not find poll button: ${e}`, 'warn')
|
||||
})
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
|
||||
|
||||
await page.click(buttonId)
|
||||
|
||||
await this.bot.utils.wait(4000)
|
||||
await this.bot.utils.wait(TIMEOUTS.LONG + 1000)
|
||||
await page.close()
|
||||
|
||||
this.bot.log('POLL', 'Completed the poll successfully')
|
||||
this.bot.log(this.bot.isMobile, 'POLL', 'Completed the poll successfully')
|
||||
} catch (error) {
|
||||
await page.close()
|
||||
this.bot.log('POLL', 'An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'POLL', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
import { RETRY_LIMITS, TIMEOUTS, DELAYS } from '../../constants'
|
||||
|
||||
|
||||
export class Quiz extends Workers {
|
||||
|
||||
async doQuiz(page: Page) {
|
||||
this.bot.log('QUIZ', 'Trying to complete quiz')
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'Trying to complete quiz')
|
||||
|
||||
try {
|
||||
// Check if the quiz has been started or not
|
||||
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { visible: true, timeout: 3000 }).then(() => true).catch(() => false)
|
||||
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: TIMEOUTS.MEDIUM_LONG }).then(() => true).catch(() => false)
|
||||
if (quizNotStarted) {
|
||||
await page.click('#rqStartQuiz')
|
||||
} else {
|
||||
this.bot.log('QUIZ', 'Quiz has already been started, trying to finish it')
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'Quiz has already been started, trying to finish it')
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.bot.utils.wait(TIMEOUTS.MEDIUM_LONG)
|
||||
|
||||
let quizData = await this.bot.browser.func.getQuizData(page)
|
||||
|
||||
// Verify quiz is actually loaded before proceeding
|
||||
const firstOptionExists = await page.waitForSelector('#rqAnswerOption0', { state: 'attached', timeout: TIMEOUTS.VERY_LONG }).then(() => true).catch(() => false)
|
||||
if (!firstOptionExists) {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'Quiz options not found - page may not have loaded correctly. Skipping.', 'warn')
|
||||
await page.close()
|
||||
return
|
||||
}
|
||||
const questionsRemaining = quizData.maxQuestions - quizData.CorrectlyAnsweredQuestionCount // Amount of questions remaining
|
||||
|
||||
// All questions
|
||||
@@ -29,17 +38,30 @@ export class Quiz extends Workers {
|
||||
const answers: string[] = []
|
||||
|
||||
for (let i = 0; i < quizData.numberOfOptions; i++) {
|
||||
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { visible: true, timeout: 10_000 })
|
||||
const answerAttribute = await answerSelector?.evaluate(el => el.getAttribute('iscorrectoption'))
|
||||
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: TIMEOUTS.DASHBOARD_WAIT }).catch(() => null)
|
||||
|
||||
if (!answerSelector) {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found - quiz structure may have changed. Skipping remaining options.`, 'warn')
|
||||
break
|
||||
}
|
||||
|
||||
const answerAttribute = await answerSelector?.evaluate((el: Element) => el.getAttribute('iscorrectoption'))
|
||||
|
||||
if (answerAttribute && answerAttribute.toLowerCase() === 'true') {
|
||||
answers.push(`#rqAnswerOption${i}`)
|
||||
}
|
||||
}
|
||||
|
||||
// If no correct answers found, skip this question
|
||||
if (answers.length === 0) {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'No correct answers found for 8-option quiz. Skipping.', 'warn')
|
||||
await page.close()
|
||||
return
|
||||
}
|
||||
|
||||
// Click the answers
|
||||
for (const answer of answers) {
|
||||
await page.waitForSelector(answer, { visible: true, timeout: 2000 })
|
||||
await page.waitForSelector(answer, { state: 'visible', timeout: DELAYS.QUIZ_ANSWER_WAIT })
|
||||
|
||||
// Click the answer on page
|
||||
await page.click(answer)
|
||||
@@ -47,44 +69,62 @@ export class Quiz extends Workers {
|
||||
const refreshSuccess = await this.bot.browser.func.waitForQuizRefresh(page)
|
||||
if (!refreshSuccess) {
|
||||
await page.close()
|
||||
this.bot.log('QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Other type quiz
|
||||
// Other type quiz, lightspeed
|
||||
} else if ([2, 3, 4].includes(quizData.numberOfOptions)) {
|
||||
quizData = await this.bot.browser.func.getQuizData(page) // Refresh Quiz Data
|
||||
const correctOption = quizData.correctAnswer
|
||||
|
||||
let answerClicked = false
|
||||
|
||||
for (let i = 0; i < quizData.numberOfOptions; i++) {
|
||||
|
||||
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { visible: true, timeout: 10_000 })
|
||||
const dataOption = await answerSelector?.evaluate(el => el.getAttribute('data-option'))
|
||||
const answerSelector = await page.waitForSelector(`#rqAnswerOption${i}`, { state: 'visible', timeout: RETRY_LIMITS.QUIZ_ANSWER_TIMEOUT }).catch(() => null)
|
||||
|
||||
if (!answerSelector) {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', `Option ${i} not found for ${quizData.numberOfOptions}-option quiz. Skipping.`, 'warn')
|
||||
continue
|
||||
}
|
||||
|
||||
const dataOption = await answerSelector?.evaluate((el: Element) => el.getAttribute('data-option'))
|
||||
|
||||
if (dataOption === correctOption) {
|
||||
// Click the answer on page
|
||||
await page.click(`#rqAnswerOption${i}`)
|
||||
answerClicked = true
|
||||
|
||||
const refreshSuccess = await this.bot.browser.func.waitForQuizRefresh(page)
|
||||
if (!refreshSuccess) {
|
||||
await page.close()
|
||||
this.bot.log('QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
|
||||
return
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
await this.bot.utils.wait(2000)
|
||||
|
||||
if (!answerClicked) {
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', `Could not find correct answer for ${quizData.numberOfOptions}-option quiz. Skipping.`, 'warn')
|
||||
await page.close()
|
||||
return
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(DELAYS.QUIZ_ANSWER_WAIT)
|
||||
}
|
||||
}
|
||||
|
||||
// Done with
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.bot.utils.wait(DELAYS.QUIZ_ANSWER_WAIT)
|
||||
await page.close()
|
||||
this.bot.log('QUIZ', 'Completed the quiz successfully')
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'Completed the quiz successfully')
|
||||
} catch (error) {
|
||||
await page.close()
|
||||
this.bot.log('QUIZ', 'An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
74
src/functions/activities/ReadToEarn.ts
Normal file
74
src/functions/activities/ReadToEarn.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { randomBytes } from 'crypto'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
import { DashboardData } from '../../interface/DashboardData'
|
||||
|
||||
|
||||
export class ReadToEarn extends Workers {
|
||||
public async doReadToEarn(accessToken: string, data: DashboardData) {
|
||||
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'Starting Read to Earn')
|
||||
|
||||
try {
|
||||
let geoLocale = data.userProfile.attributes.country
|
||||
geoLocale = (this.bot.config.searchSettings.useGeoLocaleQueries && geoLocale.length === 2) ? geoLocale.toLowerCase() : 'us'
|
||||
|
||||
const userDataRequest: AxiosRequestConfig = {
|
||||
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'X-Rewards-Country': geoLocale,
|
||||
'X-Rewards-Language': 'en'
|
||||
}
|
||||
}
|
||||
const userDataResponse = await this.bot.axios.request(userDataRequest)
|
||||
const userData = (await userDataResponse.data).response
|
||||
let userBalance = userData.balance
|
||||
|
||||
const jsonData = {
|
||||
amount: 1,
|
||||
country: geoLocale,
|
||||
id: '1',
|
||||
type: 101,
|
||||
attributes: {
|
||||
offerid: 'ENUS_readarticle3_30points'
|
||||
}
|
||||
}
|
||||
|
||||
const articleCount = 10
|
||||
for (let i = 0; i < articleCount; ++i) {
|
||||
jsonData.id = randomBytes(64).toString('hex')
|
||||
const claimRequest = {
|
||||
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
'X-Rewards-Country': geoLocale,
|
||||
'X-Rewards-Language': 'en',
|
||||
'X-Rewards-ismobile': 'true'
|
||||
},
|
||||
data: JSON.stringify(jsonData)
|
||||
}
|
||||
|
||||
const claimResponse = await this.bot.axios.request(claimRequest)
|
||||
const newBalance = (await claimResponse.data).response.balance
|
||||
|
||||
if (newBalance == userBalance) {
|
||||
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'Read all available articles')
|
||||
break
|
||||
} else {
|
||||
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', `Read article ${i + 1} of ${articleCount} max | Gained ${newBalance - userBalance} Points`)
|
||||
userBalance = newBalance
|
||||
await this.bot.utils.wait(Math.floor(this.bot.utils.randomNumber(this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.min), this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.max))))
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'Completed Read to Earn')
|
||||
} catch (error) {
|
||||
this.bot.log(this.bot.isMobile, 'READ-TO-EARN', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,228 +1,319 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import axios from 'axios'
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
import { platform } from 'os'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
import { DashboardData, DashboardImpression } from '../../interface/DashboardData'
|
||||
import { GoogleTrends } from '../../interface/GoogleDailyTrends'
|
||||
import { Counters, DashboardData } from '../../interface/DashboardData'
|
||||
import { GoogleSearch } from '../../interface/Search'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
|
||||
type GoogleTrendsResponse = [
|
||||
string,
|
||||
[
|
||||
string,
|
||||
...null[],
|
||||
[string, ...string[]]
|
||||
][]
|
||||
];
|
||||
|
||||
export class Search extends Workers {
|
||||
private bingHome = 'https://bing.com'
|
||||
private searchPageURL = ''
|
||||
|
||||
private searchPageURL = 'https://bing.com'
|
||||
public async doSearch(page: Page, data: DashboardData) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Starting Bing searches')
|
||||
|
||||
public async doSearch(page: Page, data: DashboardData, mobile: boolean) {
|
||||
this.bot.log('SEARCH-BING', 'Starting bing searches')
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
const mobileData = data.userStatus.counters?.mobileSearch ? data.userStatus.counters.mobileSearch[0] : null // Mobile searches
|
||||
const edgeData = data.userStatus.counters.pcSearch[1] as DashboardImpression // Edge searches
|
||||
const genericData = data.userStatus.counters.pcSearch[0] as DashboardImpression // Normal searches
|
||||
let searchCounters: Counters = await this.bot.browser.func.getSearchPoints()
|
||||
let missingPoints = this.calculatePoints(searchCounters)
|
||||
|
||||
let missingPoints = (mobile && mobileData) ?
|
||||
(mobileData.pointProgressMax - mobileData.pointProgress) :
|
||||
(edgeData.pointProgressMax - edgeData.pointProgress) + (genericData.pointProgressMax - genericData.pointProgress)
|
||||
|
||||
if (missingPoints == 0) {
|
||||
this.bot.log('SEARCH-BING', `Bing searches for ${mobile ? 'MOBILE' : 'DESKTOP'} have already been completed`)
|
||||
if (missingPoints === 0) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Bing searches have already been completed')
|
||||
return
|
||||
}
|
||||
|
||||
// Generate search queries
|
||||
let googleSearchQueries = await this.getGoogleTrends(data.userProfile.attributes.country, missingPoints)
|
||||
// Generate search queries (primary: Google Trends)
|
||||
const geo = this.bot.config.searchSettings.useGeoLocaleQueries ? data.userProfile.attributes.country : 'US'
|
||||
let googleSearchQueries = await this.getGoogleTrends(geo)
|
||||
|
||||
// Fallback: if trends failed or insufficient, sample from local queries file
|
||||
if (!googleSearchQueries.length || googleSearchQueries.length < 10) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Primary trends source insufficient, falling back to local queries.json', 'warn')
|
||||
try {
|
||||
const local = await import('../queries.json')
|
||||
// Flatten & sample
|
||||
const sampleSize = Math.max(5, Math.min(this.bot.config.searchSettings.localFallbackCount || 25, local.default.length))
|
||||
const sampled = this.bot.utils.shuffleArray(local.default).slice(0, sampleSize)
|
||||
googleSearchQueries = sampled.map((x: { title: string; queries: string[] }) => ({ topic: x.queries[0] || x.title, related: x.queries.slice(1) }))
|
||||
} catch (e) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Failed loading local queries fallback: ' + (e instanceof Error ? e.message : e), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
googleSearchQueries = this.bot.utils.shuffleArray(googleSearchQueries)
|
||||
|
||||
// Deduplicate the search terms
|
||||
googleSearchQueries = [...new Set(googleSearchQueries)]
|
||||
|
||||
// Open a new tab
|
||||
const browser = page.browser()
|
||||
const searchPage = await browser.newPage()
|
||||
// Deduplicate topics
|
||||
const seen = new Set<string>()
|
||||
googleSearchQueries = googleSearchQueries.filter(q => {
|
||||
if (seen.has(q.topic.toLowerCase())) return false
|
||||
seen.add(q.topic.toLowerCase())
|
||||
return true
|
||||
})
|
||||
|
||||
// Go to bing
|
||||
await searchPage.goto(this.searchPageURL)
|
||||
await page.goto(this.searchPageURL ? this.searchPageURL : this.bingHome)
|
||||
|
||||
let maxLoop = 0 // If the loop hits 10 this when not gaining any points, we're assuming it's stuck. If it ddoesn't continue after 5 more searches with alternative queries, abort search
|
||||
await this.bot.utils.wait(2000)
|
||||
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
|
||||
let stagnation = 0 // consecutive searches without point progress
|
||||
|
||||
const queries: string[] = []
|
||||
googleSearchQueries.forEach(x => queries.push(x.topic, ...x.related))
|
||||
// Mobile search doesn't seem to like related queries?
|
||||
googleSearchQueries.forEach(x => { this.bot.isMobile ? queries.push(x.topic) : queries.push(x.topic, ...x.related) })
|
||||
|
||||
// Loop over Google search queries
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
const query = queries[i] as string
|
||||
|
||||
this.bot.log('SEARCH-BING', `${missingPoints} Points Remaining | Query: ${query} | Mobile: ${mobile}`)
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `${missingPoints} Points Remaining | Query: ${query}`)
|
||||
|
||||
const newData = await this.bingSearch(page, searchPage, query)
|
||||
|
||||
const newMobileData = newData.mobileSearch ? newData.mobileSearch[0] : null // Mobile searches
|
||||
const newEdgeData = newData.pcSearch[1] as DashboardImpression // Edge searches
|
||||
const newGenericData = newData.pcSearch[0] as DashboardImpression // Normal searches
|
||||
|
||||
const newMissingPoints = (mobile && newMobileData) ?
|
||||
(newMobileData.pointProgressMax - newMobileData.pointProgress) :
|
||||
(newEdgeData.pointProgressMax - newEdgeData.pointProgress) + (newGenericData.pointProgressMax - newGenericData.pointProgress)
|
||||
searchCounters = await this.bingSearch(page, query)
|
||||
const newMissingPoints = this.calculatePoints(searchCounters)
|
||||
|
||||
// If the new point amount is the same as before
|
||||
if (newMissingPoints == missingPoints) {
|
||||
maxLoop++ // Add to max loop
|
||||
} else { // There has been a change in points
|
||||
maxLoop = 0 // Reset the loop
|
||||
if (newMissingPoints === missingPoints) {
|
||||
stagnation++
|
||||
} else {
|
||||
stagnation = 0
|
||||
}
|
||||
|
||||
missingPoints = newMissingPoints
|
||||
|
||||
if (missingPoints == 0) {
|
||||
if (missingPoints === 0) break
|
||||
|
||||
// Only for mobile searches
|
||||
if (stagnation > 5 && this.bot.isMobile) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search didn\'t gain point for 5 iterations, likely bad User-Agent', 'warn')
|
||||
break
|
||||
}
|
||||
|
||||
// If we didn't gain points for 10 iterations, assume it's stuck
|
||||
if (maxLoop > 10) {
|
||||
this.bot.log('SEARCH-BING', 'Search didn\'t gain point for 10 iterations aborting searches', 'warn')
|
||||
maxLoop = 0 // Reset to 0 so we can retry with related searches below
|
||||
if (stagnation > 10) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search didn\'t gain point for 10 iterations aborting searches', 'warn')
|
||||
stagnation = 0 // allow fallback loop below
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Only for mobile searches
|
||||
if (missingPoints > 0 && this.bot.isMobile) {
|
||||
return
|
||||
}
|
||||
|
||||
// If we still got remaining search queries, generate extra ones
|
||||
if (missingPoints > 0) {
|
||||
this.bot.log('SEARCH-BING', `Search completed but we're missing ${missingPoints} points, generating extra searches`)
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `Search completed but we're missing ${missingPoints} points, generating extra searches`)
|
||||
|
||||
let i = 0
|
||||
while (missingPoints > 0) {
|
||||
let fallbackRounds = 0
|
||||
const extraRetries = this.bot.config.searchSettings.extraFallbackRetries || 1
|
||||
while (missingPoints > 0 && fallbackRounds <= extraRetries) {
|
||||
const query = googleSearchQueries[i++] as GoogleSearch
|
||||
if (!query) break
|
||||
|
||||
// Get related search terms to the Google search queries
|
||||
const relatedTerms = await this.getRelatedTerms(query?.topic)
|
||||
if (relatedTerms.length > 3) {
|
||||
// Search for the first 2 related terms
|
||||
for (const term of relatedTerms.slice(1, 3)) {
|
||||
this.bot.log('SEARCH-BING-EXTRA', `${missingPoints} Points Remaining | Query: ${term} | Mobile: ${mobile}`)
|
||||
const newData = await this.bingSearch(page, searchPage, query.topic)
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING-EXTRA', `${missingPoints} Points Remaining | Query: ${term}`)
|
||||
|
||||
const newMobileData = newData.mobileSearch ? newData.mobileSearch[0] : null // Mobile searches
|
||||
const newEdgeData = newData.pcSearch[1] as DashboardImpression // Edge searches
|
||||
const newGenericData = newData.pcSearch[0] as DashboardImpression // Normal searches
|
||||
|
||||
const newMissingPoints = (mobile && newMobileData) ?
|
||||
(newMobileData.pointProgressMax - newMobileData.pointProgress) :
|
||||
(newEdgeData.pointProgressMax - newEdgeData.pointProgress) + (newGenericData.pointProgressMax - newGenericData.pointProgress)
|
||||
searchCounters = await this.bingSearch(page, term)
|
||||
const newMissingPoints = this.calculatePoints(searchCounters)
|
||||
|
||||
// If the new point amount is the same as before
|
||||
if (newMissingPoints == missingPoints) {
|
||||
maxLoop++ // Add to max loop
|
||||
} else { // There has been a change in points
|
||||
maxLoop = 0 // Reset the loop
|
||||
if (newMissingPoints === missingPoints) {
|
||||
stagnation++
|
||||
} else {
|
||||
stagnation = 0
|
||||
}
|
||||
|
||||
missingPoints = newMissingPoints
|
||||
|
||||
// If we satisfied the searches
|
||||
if (missingPoints == 0) {
|
||||
if (missingPoints === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
// Try 5 more times, then we tried a total of 15 times, fair to say it's stuck
|
||||
if (maxLoop > 5) {
|
||||
this.bot.log('SEARCH-BING-EXTRA', 'Search didn\'t gain point for 5 iterations aborting searches', 'warn')
|
||||
if (stagnation > 5) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING-EXTRA', 'Search didn\'t gain point for 5 iterations aborting searches', 'warn')
|
||||
return
|
||||
}
|
||||
}
|
||||
fallbackRounds++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.log('SEARCH-BING', 'Completed searches')
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Completed searches')
|
||||
}
|
||||
|
||||
private async bingSearch(page: Page, searchPage: Page, query: string) {
|
||||
private async bingSearch(searchPage: Page, query: string) {
|
||||
const platformControlKey = platform() === 'darwin' ? 'Meta' : 'Control'
|
||||
|
||||
// Try a max of 5 times
|
||||
for (let i = 0; i < 5; i++) {
|
||||
try {
|
||||
const searchBar = '#sb_form_q'
|
||||
await searchPage.waitForSelector(searchBar, { visible: true, timeout: 10_000 })
|
||||
await searchPage.click(searchBar) // Focus on the textarea
|
||||
// This page had already been set to the Bing.com page or the previous search listing, we just need to select it
|
||||
searchPage = await this.bot.browser.utils.getLatestTab(searchPage)
|
||||
|
||||
// Go to top of the page
|
||||
await searchPage.evaluate(() => {
|
||||
window.scrollTo(0, 0)
|
||||
})
|
||||
|
||||
await this.bot.utils.wait(500)
|
||||
await searchPage.keyboard.down('Control')
|
||||
await searchPage.keyboard.press('A')
|
||||
await searchPage.keyboard.press('Backspace')
|
||||
await searchPage.keyboard.up('Control')
|
||||
await searchPage.keyboard.type(query)
|
||||
await searchPage.keyboard.press('Enter')
|
||||
|
||||
const searchBar = '#sb_form_q'
|
||||
// Prefer attached over visible to avoid strict visibility waits when overlays exist
|
||||
const box = searchPage.locator(searchBar)
|
||||
await box.waitFor({ state: 'attached', timeout: 15000 })
|
||||
|
||||
// Try dismissing overlays before interacting
|
||||
await this.bot.browser.utils.tryDismissAllMessages(searchPage)
|
||||
await this.bot.utils.wait(200)
|
||||
|
||||
let navigatedDirectly = false
|
||||
try {
|
||||
// Try focusing and filling instead of clicking (more reliable on mobile)
|
||||
await box.focus({ timeout: 2000 }).catch(() => { /* ignore focus errors */ })
|
||||
await box.fill('')
|
||||
await this.bot.utils.wait(200)
|
||||
await searchPage.keyboard.down(platformControlKey)
|
||||
await searchPage.keyboard.press('A')
|
||||
await searchPage.keyboard.press('Backspace')
|
||||
await searchPage.keyboard.up(platformControlKey)
|
||||
await box.type(query, { delay: 20 })
|
||||
await searchPage.keyboard.press('Enter')
|
||||
} catch (typeErr) {
|
||||
// As a robust fallback, navigate directly to the search results URL
|
||||
const q = encodeURIComponent(query)
|
||||
const url = `https://www.bing.com/search?q=${q}`
|
||||
await searchPage.goto(url)
|
||||
navigatedDirectly = true
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(3000)
|
||||
|
||||
// Bing.com in Chrome opens a new tab when searching via Enter; if we navigated directly, stay on current tab
|
||||
const resultPage = navigatedDirectly ? searchPage : await this.bot.browser.utils.getLatestTab(searchPage)
|
||||
this.searchPageURL = new URL(resultPage.url()).href // Set the results page
|
||||
|
||||
await this.bot.browser.utils.reloadBadPage(resultPage)
|
||||
|
||||
if (this.bot.config.searchSettings.scrollRandomResults) {
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.randomScroll(searchPage)
|
||||
await this.randomScroll(resultPage)
|
||||
}
|
||||
|
||||
if (this.bot.config.searchSettings.clickRandomResults) {
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.clickRandomLink(searchPage)
|
||||
await this.clickRandomLink(resultPage)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(Math.floor(this.bot.utils.randomNumber(this.bot.config.searchSettings.searchDelay.min, this.bot.config.searchSettings.searchDelay.max)))
|
||||
// Delay between searches
|
||||
const minDelay = this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.min)
|
||||
const maxDelay = this.bot.utils.stringToMs(this.bot.config.searchSettings.searchDelay.max)
|
||||
const adaptivePad = Math.min(4000, Math.max(0, Math.floor(Math.random() * 800)))
|
||||
await this.bot.utils.wait(Math.floor(this.bot.utils.randomNumber(minDelay, maxDelay)) + adaptivePad)
|
||||
|
||||
return await this.bot.browser.func.getSearchPoints(page)
|
||||
return await this.bot.browser.func.getSearchPoints()
|
||||
|
||||
} catch (error) {
|
||||
if (i === 5) {
|
||||
this.bot.log('SEARCH-BING', 'Failed after 5 retries... An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Failed after 5 retries... An error occurred:' + error, 'error')
|
||||
break
|
||||
|
||||
}
|
||||
this.bot.log('SEARCH-BING', 'Search failed, An error occurred:' + error, 'error')
|
||||
this.bot.log('SEARCH-BING', `Retrying search, attempt ${i}/5`, 'warn')
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search failed, An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `Retrying search, attempt ${i}/5`, 'warn')
|
||||
|
||||
// Reset the tabs
|
||||
const lastTab = await this.bot.browser.utils.getLatestTab(searchPage)
|
||||
await this.closeTabs(lastTab)
|
||||
|
||||
await this.bot.utils.wait(4000)
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.log('SEARCH-BING', 'Search failed after 5 retries, ending', 'error')
|
||||
return await this.bot.browser.func.getSearchPoints(page)
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Search failed after 5 retries, ending', 'error')
|
||||
return await this.bot.browser.func.getSearchPoints()
|
||||
}
|
||||
|
||||
private async getGoogleTrends(geoLocale: string, queryCount: number): Promise<GoogleSearch[]> {
|
||||
private async getGoogleTrends(geoLocale: string = 'US'): Promise<GoogleSearch[]> {
|
||||
const queryTerms: GoogleSearch[] = []
|
||||
let i = 0
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Generating search queries, can take a while! | GeoLocale: ${geoLocale}`)
|
||||
|
||||
geoLocale = (this.bot.config.searchSettings.useGeoLocaleQueries && geoLocale.length === 2) ? geoLocale.toUpperCase() : 'US'
|
||||
|
||||
this.bot.log('SEARCH-GOOGLE-TRENDS', `Generating search queries, can take a while! | GeoLocale: ${geoLocale}`)
|
||||
|
||||
while (queryCount > queryTerms.length) {
|
||||
i += 1
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - i)
|
||||
const formattedDate = this.formatDate(date)
|
||||
|
||||
try {
|
||||
const request = {
|
||||
url: `https://trends.google.com/trends/api/dailytrends?geo=${geoLocale}&hl=en&ed=${formattedDate}&ns=15`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios(request)
|
||||
|
||||
const data: GoogleTrends = JSON.parse((await response.data).slice(5))
|
||||
|
||||
for (const topic of data.default.trendingSearchesDays[0]?.trendingSearches ?? []) {
|
||||
queryTerms.push({
|
||||
topic: topic.title.query.toLowerCase(),
|
||||
related: topic.relatedQueries.map(x => x.query.toLocaleLowerCase())
|
||||
})
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log('SEARCH-GOOGLE-TRENDS', 'An error occurred:' + error, 'error')
|
||||
try {
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://trends.google.com/_/TrendsUi/data/batchexecute',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
},
|
||||
data: `f.req=[[[i0OFE,"[null, null, \\"${geoLocale.toUpperCase()}\\", 0, null, 48]"]]]`
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request, this.bot.config.proxy.proxyGoogleTrends)
|
||||
const rawText = response.data
|
||||
|
||||
const trendsData = this.extractJsonFromResponse(rawText)
|
||||
if (!trendsData) {
|
||||
throw this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'Failed to parse Google Trends response', 'error')
|
||||
}
|
||||
|
||||
const mappedTrendsData = trendsData.map(query => [query[0], query[9]!.slice(1)])
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Found ${mappedTrendsData.length} search queries for ${geoLocale}`)
|
||||
|
||||
if (mappedTrendsData.length < 30 && geoLocale.toUpperCase() !== 'US') {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', `Insufficient search queries (${mappedTrendsData.length} < 30), falling back to US`, 'warn')
|
||||
return this.getGoogleTrends()
|
||||
}
|
||||
|
||||
for (const [topic, relatedQueries] of mappedTrendsData) {
|
||||
queryTerms.push({
|
||||
topic: topic as string,
|
||||
related: relatedQueries as string[]
|
||||
})
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
|
||||
return queryTerms
|
||||
}
|
||||
|
||||
private extractJsonFromResponse(text: string): GoogleTrendsResponse[1] | null {
|
||||
const lines = text.split('\n')
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
try {
|
||||
return JSON.parse(JSON.parse(trimmed)[0][2])[1]
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async getRelatedTerms(term: string): Promise<string[]> {
|
||||
try {
|
||||
const request = {
|
||||
@@ -233,101 +324,121 @@ export class Search extends Workers {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios(request)
|
||||
const response = await this.bot.axios.request(request, this.bot.config.proxy.proxyBingTerms)
|
||||
|
||||
return response.data[1] as string[]
|
||||
} catch (error) {
|
||||
this.bot.log('SEARCH-BING-RELTATED', 'An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING-RELATED', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
|
||||
return `${year}${month}${day}`
|
||||
}
|
||||
|
||||
private async randomScroll(page: Page) {
|
||||
try {
|
||||
// Press the arrow down key to scroll
|
||||
for (let i = 0; i < this.bot.utils.randomNumber(5, 100); i++) {
|
||||
await page.keyboard.press('ArrowDown')
|
||||
}
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight)
|
||||
const totalHeight = await page.evaluate(() => document.body.scrollHeight)
|
||||
const randomScrollPosition = Math.floor(Math.random() * (totalHeight - viewportHeight))
|
||||
|
||||
await page.evaluate((scrollPos: number) => {
|
||||
window.scrollTo(0, scrollPos)
|
||||
}, randomScrollPosition)
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log('SEARCH-RANDOM-SCROLL', 'An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-RANDOM-SCROLL', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
private async clickRandomLink(page: Page) {
|
||||
try {
|
||||
const searchListingURL = new URL(page.url()) // Get searchPage info before clicking
|
||||
await page.click('#b_results .b_algo h2', { timeout: 2000 }).catch(() => { }) // Since we don't really care if it did it or not
|
||||
|
||||
await page.click('#b_results .b_algo h2').catch(() => { }) // Since we don't really care if it did it or not
|
||||
// Only used if the browser is not the edge browser (continue on Edge popup)
|
||||
await this.closeContinuePopup(page)
|
||||
|
||||
// Wait for website to load
|
||||
await this.bot.utils.wait(3000)
|
||||
// Stay for 10 seconds for page to load and "visit"
|
||||
await this.bot.utils.wait(10000)
|
||||
|
||||
// Will get current tab if no new one is created
|
||||
// Will get current tab if no new one is created, this will always be the visited site or the result page if it failed to click
|
||||
let lastTab = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
// Wait for the body of the new page to be loaded
|
||||
await lastTab.waitForSelector('body', { timeout: 10_000 }).catch(() => { })
|
||||
let lastTabURL = new URL(lastTab.url()) // Get new tab info, this is the website we're visiting
|
||||
|
||||
// Check if the tab is closed or not
|
||||
if (!lastTab.isClosed()) {
|
||||
let lastTabURL = new URL(lastTab.url()) // Get new tab info
|
||||
// Check if the URL is different from the original one, don't loop more than 5 times.
|
||||
let i = 0
|
||||
while (lastTabURL.href !== this.searchPageURL && i < 5) {
|
||||
|
||||
// Check if the URL is different from the original one, don't loop more than 5 times.
|
||||
let i = 0
|
||||
while (lastTabURL.href !== searchListingURL.href && i < 5) {
|
||||
// If hostname is still bing, (Bing images/news etc)
|
||||
if (lastTabURL.hostname == searchListingURL.hostname) {
|
||||
await lastTab.goBack()
|
||||
await this.closeTabs(lastTab)
|
||||
|
||||
lastTab = await this.bot.browser.utils.getLatestTab(page) // Get last opened tab
|
||||
// End of loop, refresh lastPage
|
||||
lastTab = await this.bot.browser.utils.getLatestTab(page) // Finally update the lastTab var again
|
||||
lastTabURL = new URL(lastTab.url()) // Get new tab info
|
||||
i++
|
||||
}
|
||||
|
||||
// If "goBack" didn't return to search listing (due to redirects)
|
||||
if (lastTabURL.hostname !== searchListingURL.hostname) {
|
||||
await lastTab.goto(this.searchPageURL)
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-RANDOM-CLICK', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
} else { // No longer on bing, likely opened a new tab, close this tab
|
||||
lastTab = await this.bot.browser.utils.getLatestTab(page) // Get last opened tab
|
||||
lastTabURL = new URL(lastTab.url())
|
||||
private async closeTabs(lastTab: Page) {
|
||||
const browser = lastTab.context()
|
||||
const tabs = browser.pages()
|
||||
|
||||
const tabs = await (page.browser()).pages() // Get all tabs open
|
||||
try {
|
||||
if (tabs.length > 2) {
|
||||
// If more than 2 tabs are open, close the last tab
|
||||
|
||||
// If the browser has more than 3 tabs open, it has opened a new one, we need to close this one.
|
||||
if (tabs.length > 3) {
|
||||
// Make sure the page is still open!
|
||||
if (!lastTab.isClosed()) {
|
||||
await lastTab.close()
|
||||
}
|
||||
await lastTab.close()
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-CLOSE-TABS', `More than 2 were open, closed the last tab: "${new URL(lastTab.url()).host}"`)
|
||||
|
||||
} else if (lastTabURL.href !== searchListingURL.href) {
|
||||
} else if (tabs.length === 1) {
|
||||
// If only 1 tab is open, open a new one to search in
|
||||
|
||||
await lastTab.goBack()
|
||||
const newPage = await browser.newPage()
|
||||
await this.bot.utils.wait(1000)
|
||||
|
||||
lastTab = await this.bot.browser.utils.getLatestTab(page) // Get last opened tab
|
||||
lastTabURL = new URL(lastTab.url())
|
||||
await newPage.goto(this.bingHome)
|
||||
await this.bot.utils.wait(3000)
|
||||
this.searchPageURL = newPage.url()
|
||||
|
||||
// If "goBack" didn't return to search listing (due to redirects)
|
||||
if (lastTabURL.hostname !== searchListingURL.hostname) {
|
||||
await lastTab.goto(this.searchPageURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
lastTab = await this.bot.browser.utils.getLatestTab(page) // Finally update the lastTab var again
|
||||
i++
|
||||
}
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-CLOSE-TABS', 'There was only 1 tab open, crated a new one')
|
||||
} else {
|
||||
// Else reset the last tab back to the search listing or Bing.com
|
||||
|
||||
lastTab = await this.bot.browser.utils.getLatestTab(lastTab)
|
||||
await lastTab.goto(this.searchPageURL ? this.searchPageURL : this.bingHome)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-CLOSE-TABS', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private calculatePoints(counters: Counters) {
|
||||
const mobileData = counters.mobileSearch?.[0] // Mobile searches
|
||||
const genericData = counters.pcSearch?.[0] // Normal searches
|
||||
const edgeData = counters.pcSearch?.[1] // Edge searches
|
||||
|
||||
const missingPoints = (this.bot.isMobile && mobileData)
|
||||
? mobileData.pointProgressMax - mobileData.pointProgress
|
||||
: (edgeData ? edgeData.pointProgressMax - edgeData.pointProgress : 0)
|
||||
+ (genericData ? genericData.pointProgressMax - genericData.pointProgress : 0)
|
||||
|
||||
return missingPoints
|
||||
}
|
||||
|
||||
private async closeContinuePopup(page: Page) {
|
||||
try {
|
||||
await page.waitForSelector('#sacs_close', { timeout: 1000 })
|
||||
const continueButton = await page.$('#sacs_close')
|
||||
|
||||
if (continueButton) {
|
||||
await continueButton.click()
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.log('SEARCH-RANDOM-CLICK', 'An error occurred:' + error, 'error')
|
||||
// Continue if element is not found or other error occurs
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
85
src/functions/activities/SearchOnBing.ts
Normal file
85
src/functions/activities/SearchOnBing.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { Page } from 'playwright'
|
||||
import * as fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
import { DELAYS } from '../../constants'
|
||||
|
||||
import { MorePromotion, PromotionalItem } from '../../interface/DashboardData'
|
||||
|
||||
|
||||
export class SearchOnBing extends Workers {
|
||||
|
||||
async doSearchOnBing(page: Page, activity: MorePromotion | PromotionalItem) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING', 'Trying to complete SearchOnBing')
|
||||
|
||||
try {
|
||||
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_WAIT)
|
||||
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
|
||||
const query = await this.getSearchQuery(activity.title)
|
||||
|
||||
const searchBar = '#sb_form_q'
|
||||
const box = page.locator(searchBar)
|
||||
await box.waitFor({ state: 'attached', timeout: DELAYS.SEARCH_BAR_TIMEOUT })
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_FOCUS)
|
||||
try {
|
||||
await box.focus({ timeout: DELAYS.THIS_OR_THAT_START }).catch(() => { /* ignore */ })
|
||||
await box.fill('')
|
||||
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_FOCUS)
|
||||
await page.keyboard.type(query, { delay: DELAYS.TYPING_DELAY })
|
||||
await page.keyboard.press('Enter')
|
||||
} catch {
|
||||
const url = `https://www.bing.com/search?q=${encodeURIComponent(query)}`
|
||||
await page.goto(url)
|
||||
}
|
||||
await this.bot.utils.wait(DELAYS.SEARCH_ON_BING_COMPLETE)
|
||||
|
||||
await page.close()
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING', 'Completed the SearchOnBing successfully')
|
||||
} catch (error) {
|
||||
await page.close()
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
private async getSearchQuery(title: string): Promise<string> {
|
||||
interface Queries {
|
||||
title: string;
|
||||
queries: string[]
|
||||
}
|
||||
|
||||
let queries: Queries[] = []
|
||||
|
||||
try {
|
||||
if (this.bot.config.searchOnBingLocalQueries) {
|
||||
const data = fs.readFileSync(path.join(__dirname, '../queries.json'), 'utf8')
|
||||
queries = JSON.parse(data)
|
||||
} else {
|
||||
// Fetch from the repo directly so the user doesn't need to redownload the script for the new activities
|
||||
const response = await this.bot.axios.request({
|
||||
method: 'GET',
|
||||
url: 'https://raw.githubusercontent.com/TheNetsky/Microsoft-Rewards-Script/refs/heads/v2/src/functions/queries.json'
|
||||
})
|
||||
queries = response.data
|
||||
}
|
||||
|
||||
const answers = queries.find(x => this.normalizeString(x.title) === this.normalizeString(title))
|
||||
const answer = answers ? this.bot.utils.shuffleArray(answers?.queries)[0] as string : title
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING-QUERY', `Fetched answer: ${answer} | question: ${title}`)
|
||||
return answer
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-ON-BING-QUERY', 'An error occurred:' + error, 'error')
|
||||
return title
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeString(string: string): string {
|
||||
return string.normalize('NFD').trim().toLowerCase().replace(/[^\x20-\x7E]/g, '').replace(/[?!]/g, '')
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,47 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
import { DELAYS } from '../../constants'
|
||||
|
||||
|
||||
export class ThisOrThat extends Workers {
|
||||
|
||||
async doThisOrThat(page: Page) {
|
||||
this.bot.log('THIS-OR-THAT', 'Trying to complete ThisOrThat')
|
||||
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'Trying to complete ThisOrThat')
|
||||
|
||||
|
||||
return
|
||||
try {
|
||||
// Check if the quiz has been started or not
|
||||
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { visible: true, timeout: 3000 }).then(() => true).catch(() => false)
|
||||
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { state: 'visible', timeout: DELAYS.THIS_OR_THAT_START }).then(() => true).catch(() => false)
|
||||
if (quizNotStarted) {
|
||||
await page.click('#rqStartQuiz')
|
||||
} else {
|
||||
this.bot.log('THIS-OR-THAT', 'ThisOrThat has already been started, trying to finish it')
|
||||
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'ThisOrThat has already been started, trying to finish it')
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.bot.utils.wait(DELAYS.THIS_OR_THAT_START)
|
||||
|
||||
// Solving
|
||||
const quizData = await this.bot.browser.func.getQuizData(page)
|
||||
quizData // correctAnswer property is always null?
|
||||
const questionsRemaining = quizData.maxQuestions - (quizData.currentQuestionNumber - 1) // Amount of questions remaining
|
||||
|
||||
this.bot.log('THIS-OR-THAT', 'Completed the ThisOrthat successfully')
|
||||
for (let question = 0; question < questionsRemaining; question++) {
|
||||
// Since there's no solving logic yet, randomly guess to complete
|
||||
const buttonId = `#rqAnswerOption${Math.floor(this.bot.utils.randomNumber(0, 1))}`
|
||||
await page.click(buttonId)
|
||||
|
||||
const refreshSuccess = await this.bot.browser.func.waitForQuizRefresh(page)
|
||||
if (!refreshSuccess) {
|
||||
await page.close()
|
||||
this.bot.log(this.bot.isMobile, 'QUIZ', 'An error occurred, refresh was unsuccessful', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'Completed the ThisOrThat successfully')
|
||||
} catch (error) {
|
||||
await page.close()
|
||||
this.bot.log('THIS-OR-THAT', 'An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'THIS-OR-THAT', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
@@ -6,16 +6,17 @@ import { Workers } from '../Workers'
|
||||
export class UrlReward extends Workers {
|
||||
|
||||
async doUrlReward(page: Page) {
|
||||
this.bot.log('URL-REWARD', 'Trying to complete UrlReward')
|
||||
this.bot.log(this.bot.isMobile, 'URL-REWARD', 'Trying to complete UrlReward')
|
||||
|
||||
try {
|
||||
// After waiting, close the page
|
||||
await this.bot.utils.wait(2000)
|
||||
|
||||
await page.close()
|
||||
|
||||
this.bot.log('URL-REWARD', 'Completed the UrlReward successfully')
|
||||
this.bot.log(this.bot.isMobile, 'URL-REWARD', 'Completed the UrlReward successfully')
|
||||
} catch (error) {
|
||||
await page.close()
|
||||
this.bot.log('URL-REWARD', 'An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'URL-REWARD', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
289
src/functions/queries.json
Normal file
289
src/functions/queries.json
Normal file
@@ -0,0 +1,289 @@
|
||||
[
|
||||
{
|
||||
"title": "Houses near you",
|
||||
"queries": [
|
||||
"Houses near me"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Feeling symptoms?",
|
||||
"queries": [
|
||||
"Rash on forearm",
|
||||
"Stuffy nose",
|
||||
"Tickling cough"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Get your shopping done faster",
|
||||
"queries": [
|
||||
"Buy PS5",
|
||||
"Buy Xbox",
|
||||
"Chair deals"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Translate anything",
|
||||
"queries": [
|
||||
"Translate welcome home to Korean",
|
||||
"Translate welcome home to Japanese",
|
||||
"Translate goodbye to Japanese"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Search the lyrics of a song",
|
||||
"queries": [
|
||||
"Debarge rhythm of the night lyrics"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Let's watch that movie again!",
|
||||
"queries": [
|
||||
"Alien movie",
|
||||
"Aliens movie",
|
||||
"Alien 3 movie",
|
||||
"Predator movie"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Plan a quick getaway",
|
||||
"queries": [
|
||||
"Flights Amsterdam to Tokyo",
|
||||
"Flights New York to Tokyo"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Discover open job roles",
|
||||
"queries": [
|
||||
"jobs at Microsoft",
|
||||
"Microsoft Job Openings",
|
||||
"Jobs near me",
|
||||
"jobs at Boeing worked"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "You can track your package",
|
||||
"queries": [
|
||||
"USPS tracking"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Find somewhere new to explore",
|
||||
"queries": [
|
||||
"Directions to Berlin",
|
||||
"Directions to Tokyo",
|
||||
"Directions to New York"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Too tired to cook tonight?",
|
||||
"queries": [
|
||||
"KFC near me",
|
||||
"Burger King near me",
|
||||
"McDonalds near me"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Quickly convert your money",
|
||||
"queries": [
|
||||
"convert 250 USD to yen",
|
||||
"convert 500 USD to yen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Learn to cook a new recipe",
|
||||
"queries": [
|
||||
"How to cook ratatouille",
|
||||
"How to cook lasagna"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Find places to stay!",
|
||||
"queries": [
|
||||
"Hotels Berlin Germany",
|
||||
"Hotels Amsterdam Netherlands"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "How's the economy?",
|
||||
"queries": [
|
||||
"sp 500"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Who won?",
|
||||
"queries": [
|
||||
"braves score"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Gaming time",
|
||||
"queries": [
|
||||
"Overwatch video game",
|
||||
"Call of duty video game"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Expand your vocabulary",
|
||||
"queries": [
|
||||
"definition definition"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "What time is it?",
|
||||
"queries": [
|
||||
"Japan time",
|
||||
"New York time"
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
"title": "Maisons près de chez vous",
|
||||
"queries": [
|
||||
"Maisons près de chez moi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Vous ressentez des symptômes ?",
|
||||
"queries": [
|
||||
"Éruption cutanée sur l'avant-bras",
|
||||
"Nez bouché",
|
||||
"Toux chatouilleuse"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Faites vos achats plus vite",
|
||||
"queries": [
|
||||
"Acheter une PS5",
|
||||
"Acheter une Xbox",
|
||||
"Offres sur les chaises"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Traduisez tout !",
|
||||
"queries": [
|
||||
"Traduction bienvenue à la maison en coréen",
|
||||
"Traduction bienvenue à la maison en japonais",
|
||||
"Traduction au revoir en japonais"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Rechercher paroles de chanson",
|
||||
"queries": [
|
||||
"Paroles de Debarge rhythm of the night"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Et si nous regardions ce film une nouvelle fois?",
|
||||
"queries": [
|
||||
"Alien film",
|
||||
"Film Aliens",
|
||||
"Film Alien 3",
|
||||
"Film Predator"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Planifiez une petite escapade",
|
||||
"queries": [
|
||||
"Vols Amsterdam-Tokyo",
|
||||
"Vols New York-Tokyo"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Consulter postes à pourvoir",
|
||||
"queries": [
|
||||
"emplois chez Microsoft",
|
||||
"Offres d'emploi Microsoft",
|
||||
"Emplois près de chez moi",
|
||||
"emplois chez Boeing"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Vous pouvez suivre votre colis",
|
||||
"queries": [
|
||||
"Suivi Chronopost"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Trouver un endroit à découvrir",
|
||||
"queries": [
|
||||
"Itinéraire vers Berlin",
|
||||
"Itinéraire vers Tokyo",
|
||||
"Itinéraire vers New York"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Trop fatigué pour cuisiner ce soir ?",
|
||||
"queries": [
|
||||
"KFC près de chez moi",
|
||||
"Burger King près de chez moi",
|
||||
"McDonalds près de chez moi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Convertissez rapidement votre argent",
|
||||
"queries": [
|
||||
"convertir 250 EUR en yen",
|
||||
"convertir 500 EUR en yen"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Apprenez à cuisiner une nouvelle recette",
|
||||
"queries": [
|
||||
"Comment faire cuire la ratatouille",
|
||||
"Comment faire cuire les lasagnes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Trouvez des emplacements pour rester!",
|
||||
"queries": [
|
||||
"Hôtels Berlin Allemagne",
|
||||
"Hôtels Amsterdam Pays-Bas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Comment se porte l'économie ?",
|
||||
"queries": [
|
||||
"CAC 40"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Qui a gagné ?",
|
||||
"queries": [
|
||||
"score du Paris Saint-Germain"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Temps de jeu",
|
||||
"queries": [
|
||||
"Jeu vidéo Overwatch",
|
||||
"Jeu vidéo Call of Duty"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Enrichissez votre vocabulaire",
|
||||
"queries": [
|
||||
"definition definition"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Quelle heure est-il ?",
|
||||
"queries": [
|
||||
"Heure du Japon",
|
||||
"Heure de New York"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Vérifier la météo",
|
||||
"queries": [
|
||||
"Météo de Paris",
|
||||
"Météo de la France"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Tenez-vous informé des sujets d'actualité",
|
||||
"queries": [
|
||||
"Augmentation Impots",
|
||||
"Mort célébrité"
|
||||
]
|
||||
}
|
||||
]
|
||||
817
src/index.ts
817
src/index.ts
@@ -1,4 +1,7 @@
|
||||
import cluster from 'cluster'
|
||||
import type { Worker } from 'cluster'
|
||||
// Use Page type from playwright for typings; at runtime rebrowser-playwright extends playwright
|
||||
import type { Page } from 'playwright'
|
||||
|
||||
import Browser from './browser/Browser'
|
||||
import BrowserFunc from './browser/BrowserFunc'
|
||||
@@ -6,13 +9,20 @@ import BrowserUtil from './browser/BrowserUtil'
|
||||
|
||||
import { log } from './util/Logger'
|
||||
import Util from './util/Utils'
|
||||
import { loadAccounts, loadConfig } from './util/Load'
|
||||
import { loadAccounts, loadConfig, saveSessionData } from './util/Load'
|
||||
import { DISCORD } from './constants'
|
||||
|
||||
import { Login } from './functions/Login'
|
||||
import { Workers } from './functions/Workers'
|
||||
import Activities from './functions/Activities'
|
||||
|
||||
import { Account } from './interface/Account'
|
||||
import Axios from './util/Axios'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import Humanizer from './util/Humanizer'
|
||||
import { detectBanReason } from './util/BanDetector'
|
||||
|
||||
|
||||
// Main bot class
|
||||
export class MicrosoftRewardsBot {
|
||||
@@ -24,34 +34,63 @@ export class MicrosoftRewardsBot {
|
||||
func: BrowserFunc,
|
||||
utils: BrowserUtil
|
||||
}
|
||||
public humanizer: Humanizer
|
||||
public isMobile: boolean
|
||||
public homePage!: Page
|
||||
public currentAccountEmail?: string
|
||||
public currentAccountRecoveryEmail?: string
|
||||
public compromisedModeActive: boolean = false
|
||||
public compromisedReason?: string
|
||||
public compromisedEmail?: string
|
||||
// Mutex-like flag to prevent parallel execution when config.parallel is accidentally misconfigured
|
||||
private isDesktopRunning: boolean = false
|
||||
private isMobileRunning: boolean = false
|
||||
|
||||
private pointsCanCollect: number = 0
|
||||
private pointsInitial: number = 0
|
||||
|
||||
private collectedPoints: number = 0
|
||||
private activeWorkers: number
|
||||
private mobileRetryAttempts: number
|
||||
private browserFactory: Browser = new Browser(this)
|
||||
private accounts: Account[]
|
||||
private workers: Workers
|
||||
private login = new Login(this)
|
||||
private accessToken: string = ''
|
||||
|
||||
constructor() {
|
||||
// Summary collection (per process)
|
||||
private accountSummaries: AccountSummary[] = []
|
||||
private runId: string = Math.random().toString(36).slice(2)
|
||||
private bannedTriggered: { email: string; reason: string } | null = null
|
||||
private globalStandby: { active: boolean; reason?: string } = { active: false }
|
||||
|
||||
public axios!: Axios
|
||||
|
||||
constructor(isMobile: boolean) {
|
||||
this.isMobile = isMobile
|
||||
this.log = log
|
||||
|
||||
this.accounts = []
|
||||
this.utils = new Util()
|
||||
this.workers = new Workers(this)
|
||||
this.config = loadConfig()
|
||||
this.browser = {
|
||||
func: new BrowserFunc(this),
|
||||
utils: new BrowserUtil(this)
|
||||
}
|
||||
this.config = loadConfig()
|
||||
this.workers = new Workers(this)
|
||||
this.humanizer = new Humanizer(this.utils, this.config.humanization)
|
||||
this.activeWorkers = this.config.clusters
|
||||
this.mobileRetryAttempts = 0
|
||||
}
|
||||
|
||||
|
||||
async initialize() {
|
||||
this.accounts = loadAccounts()
|
||||
}
|
||||
|
||||
async run() {
|
||||
log('MAIN', `Bot started with ${this.config.clusters} clusters`)
|
||||
log('main', 'MAIN', `Bot started with ${this.config.clusters} clusters`)
|
||||
|
||||
|
||||
|
||||
// Only cluster when there's more than 1 cluster demanded
|
||||
if (this.config.clusters > 1) {
|
||||
@@ -61,180 +100,778 @@ export class MicrosoftRewardsBot {
|
||||
this.runWorker()
|
||||
}
|
||||
} else {
|
||||
this.runTasks(this.accounts)
|
||||
await this.runTasks(this.accounts)
|
||||
}
|
||||
}
|
||||
|
||||
// Return summaries (used when clusters==1)
|
||||
public getSummaries() {
|
||||
return this.accountSummaries
|
||||
}
|
||||
|
||||
private runMaster() {
|
||||
log('MAIN-PRIMARY', 'Primary process started')
|
||||
log('main', 'MAIN-PRIMARY', 'Primary process started')
|
||||
|
||||
const accountChunks = this.utils.chunkArray(this.accounts, this.config.clusters)
|
||||
const totalAccounts = this.accounts.length
|
||||
|
||||
// Validate accounts exist
|
||||
if (totalAccounts === 0) {
|
||||
log('main', 'MAIN-PRIMARY', 'No accounts found to process. Exiting.', 'warn')
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
// If user over-specified clusters (e.g. 10 clusters but only 2 accounts), don't spawn useless idle workers.
|
||||
const workerCount = Math.min(this.config.clusters, totalAccounts)
|
||||
const accountChunks = this.utils.chunkArray(this.accounts, workerCount)
|
||||
// Reset activeWorkers to actual spawn count (constructor used raw clusters)
|
||||
this.activeWorkers = workerCount
|
||||
|
||||
for (let i = 0; i < accountChunks.length; i++) {
|
||||
for (let i = 0; i < workerCount; i++) {
|
||||
const worker = cluster.fork()
|
||||
const chunk = accountChunks[i]
|
||||
worker.send({ chunk })
|
||||
const chunk = accountChunks[i] || []
|
||||
|
||||
// Validate chunk has accounts
|
||||
if (chunk.length === 0) {
|
||||
log('main', 'MAIN-PRIMARY', `Warning: Worker ${i} received empty account chunk`, 'warn')
|
||||
}
|
||||
|
||||
(worker as unknown as { send?: (m: { chunk: Account[] }) => void }).send?.({ chunk })
|
||||
worker.on('message', (msg: unknown) => {
|
||||
const m = msg as { type?: string; data?: AccountSummary[] }
|
||||
if (m && m.type === 'summary' && Array.isArray(m.data)) {
|
||||
this.accountSummaries.push(...m.data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
cluster.on('exit', (worker, code) => {
|
||||
cluster.on('exit', (worker: Worker, code: number) => {
|
||||
this.activeWorkers -= 1
|
||||
|
||||
log('MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn')
|
||||
log('main', 'MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn')
|
||||
|
||||
// Optional: restart crashed worker (basic heuristic) if crashRecovery allows
|
||||
try {
|
||||
const cr = this.config.crashRecovery
|
||||
if (cr?.restartFailedWorker && code !== 0) {
|
||||
const attempts = (worker as unknown as { _restartAttempts?: number })._restartAttempts || 0
|
||||
if (attempts < (cr.restartFailedWorkerAttempts ?? 1)) {
|
||||
(worker as unknown as { _restartAttempts?: number })._restartAttempts = attempts + 1
|
||||
log('main','CRASH-RECOVERY',`Respawning worker (attempt ${attempts + 1})`, 'warn','yellow')
|
||||
const newW = cluster.fork()
|
||||
// NOTE: account chunk re-assignment simplistic: unused; real mapping improvement todo
|
||||
newW.on('message', (msg: unknown) => {
|
||||
const m = msg as { type?: string; data?: AccountSummary[] }
|
||||
if (m && m.type === 'summary' && Array.isArray(m.data)) this.accountSummaries.push(...m.data)
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Check if all workers have exited
|
||||
if (this.activeWorkers === 0) {
|
||||
log('MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
|
||||
process.exit(0)
|
||||
// All workers done
|
||||
(async () => {
|
||||
try {
|
||||
await this.sendConclusion(this.accountSummaries)
|
||||
} catch {/* ignore */}
|
||||
log('main', 'MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
|
||||
process.exit(0)
|
||||
})()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private runWorker() {
|
||||
log('MAIN-WORKER', `Worker ${process.pid} spawned`)
|
||||
log('main', 'MAIN-WORKER', `Worker ${process.pid} spawned`)
|
||||
// Receive the chunk of accounts from the master
|
||||
process.on('message', async ({ chunk }) => {
|
||||
;(process as unknown as { on: (ev: 'message', cb: (m: { chunk: Account[] }) => void) => void }).on('message', async ({ chunk }: { chunk: Account[] }) => {
|
||||
await this.runTasks(chunk)
|
||||
})
|
||||
}
|
||||
|
||||
private async runTasks(accounts: Account[]) {
|
||||
for (const account of accounts) {
|
||||
log('MAIN-WORKER', `Started tasks for account ${account.email}`)
|
||||
// If a global standby is active due to security/banned, stop processing further accounts
|
||||
if (this.globalStandby.active) {
|
||||
log('main','SECURITY',`Global standby active (${this.globalStandby.reason || 'security-issue'}). Not proceeding to next accounts until resolved.`, 'warn', 'yellow')
|
||||
break
|
||||
}
|
||||
// Optional global stop after first ban
|
||||
if (this.config?.humanization?.stopOnBan === true && this.bannedTriggered) {
|
||||
log('main','TASK',`Stopping remaining accounts due to ban on ${this.bannedTriggered.email}: ${this.bannedTriggered.reason}`,'warn')
|
||||
break
|
||||
}
|
||||
// Reset compromised state per account
|
||||
this.compromisedModeActive = false
|
||||
this.compromisedReason = undefined
|
||||
this.compromisedEmail = undefined
|
||||
// If humanization allowed windows are configured, wait until within a window
|
||||
try {
|
||||
const windows: string[] | undefined = this.config?.humanization?.allowedWindows
|
||||
if (Array.isArray(windows) && windows.length > 0) {
|
||||
const waitMs = this.computeWaitForAllowedWindow(windows)
|
||||
if (waitMs > 0) {
|
||||
log('main','HUMANIZATION',`Waiting ${Math.ceil(waitMs/1000)}s until next allowed window before starting ${account.email}`,'warn')
|
||||
await new Promise<void>(r => setTimeout(r, waitMs))
|
||||
}
|
||||
}
|
||||
} catch {/* ignore */}
|
||||
this.currentAccountEmail = account.email
|
||||
this.currentAccountRecoveryEmail = account.recoveryEmail
|
||||
log('main', 'MAIN-WORKER', `Started tasks for account ${account.email}`)
|
||||
|
||||
// Desktop Searches, DailySet and More Promotions
|
||||
await this.Desktop(account)
|
||||
const accountStart = Date.now()
|
||||
let desktopInitial = 0
|
||||
let mobileInitial = 0
|
||||
let desktopCollected = 0
|
||||
let mobileCollected = 0
|
||||
const errors: string[] = []
|
||||
const banned = { status: false, reason: '' }
|
||||
|
||||
// If runOnZeroPoints is false and 0 points to earn, stop and try the next account
|
||||
if (!this.config.runOnZeroPoints && this.collectedPoints === 0) {
|
||||
continue
|
||||
this.axios = new Axios(account.proxy)
|
||||
const verbose = process.env.DEBUG_REWARDS_VERBOSE === '1'
|
||||
const formatFullErr = (label: string, e: unknown) => {
|
||||
const base = shortErr(e)
|
||||
if (verbose && e instanceof Error) {
|
||||
return `${label}:${base} :: ${e.stack?.split('\n').slice(0,4).join(' | ')}`
|
||||
}
|
||||
return `${label}:${base}`
|
||||
}
|
||||
|
||||
// Mobile Searches
|
||||
await this.Mobile(account)
|
||||
if (this.config.parallel) {
|
||||
const mobileInstance = new MicrosoftRewardsBot(true)
|
||||
mobileInstance.axios = this.axios
|
||||
// Run both and capture results with detailed logging
|
||||
const desktopPromise = this.Desktop(account).catch(e => {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
|
||||
const bd = detectBanReason(e)
|
||||
if (bd.status) {
|
||||
banned.status = true; banned.reason = bd.reason.substring(0,200)
|
||||
void this.handleImmediateBanAlert(account.email, banned.reason)
|
||||
}
|
||||
errors.push(formatFullErr('desktop', e)); return null
|
||||
})
|
||||
const mobilePromise = mobileInstance.Mobile(account).catch(e => {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
|
||||
const bd = detectBanReason(e)
|
||||
if (bd.status) {
|
||||
banned.status = true; banned.reason = bd.reason.substring(0,200)
|
||||
void this.handleImmediateBanAlert(account.email, banned.reason)
|
||||
}
|
||||
errors.push(formatFullErr('mobile', e)); return null
|
||||
})
|
||||
const [desktopResult, mobileResult] = await Promise.allSettled([desktopPromise, mobilePromise])
|
||||
|
||||
// Handle desktop result
|
||||
if (desktopResult.status === 'fulfilled' && desktopResult.value) {
|
||||
desktopInitial = desktopResult.value.initialPoints
|
||||
desktopCollected = desktopResult.value.collectedPoints
|
||||
} else if (desktopResult.status === 'rejected') {
|
||||
log(false, 'TASK', `Desktop promise rejected unexpectedly: ${shortErr(desktopResult.reason)}`,'error')
|
||||
errors.push(formatFullErr('desktop-rejected', desktopResult.reason))
|
||||
}
|
||||
|
||||
// Handle mobile result
|
||||
if (mobileResult.status === 'fulfilled' && mobileResult.value) {
|
||||
mobileInitial = mobileResult.value.initialPoints
|
||||
mobileCollected = mobileResult.value.collectedPoints
|
||||
} else if (mobileResult.status === 'rejected') {
|
||||
log(true, 'TASK', `Mobile promise rejected unexpectedly: ${shortErr(mobileResult.reason)}`,'error')
|
||||
errors.push(formatFullErr('mobile-rejected', mobileResult.reason))
|
||||
}
|
||||
} else {
|
||||
// Sequential execution with safety checks
|
||||
if (this.isDesktopRunning || this.isMobileRunning) {
|
||||
log('main', 'TASK', `Race condition detected: Desktop=${this.isDesktopRunning}, Mobile=${this.isMobileRunning}. Skipping to prevent conflicts.`, 'error')
|
||||
errors.push('race-condition-detected')
|
||||
} else {
|
||||
this.isMobile = false
|
||||
this.isDesktopRunning = true
|
||||
const desktopResult = await this.Desktop(account).catch(e => {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
log(false, 'TASK', `Desktop flow failed early for ${account.email}: ${msg}`,'error')
|
||||
const bd = detectBanReason(e)
|
||||
if (bd.status) {
|
||||
banned.status = true; banned.reason = bd.reason.substring(0,200)
|
||||
void this.handleImmediateBanAlert(account.email, banned.reason)
|
||||
}
|
||||
errors.push(formatFullErr('desktop', e)); return null
|
||||
})
|
||||
if (desktopResult) {
|
||||
desktopInitial = desktopResult.initialPoints
|
||||
desktopCollected = desktopResult.collectedPoints
|
||||
}
|
||||
this.isDesktopRunning = false
|
||||
|
||||
log('MAIN-WORKER', `Completed tasks for account ${account.email}`)
|
||||
// If banned or compromised detected, skip mobile to save time
|
||||
if (!banned.status && !this.compromisedModeActive) {
|
||||
this.isMobile = true
|
||||
this.isMobileRunning = true
|
||||
const mobileResult = await this.Mobile(account).catch(e => {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
log(true, 'TASK', `Mobile flow failed early for ${account.email}: ${msg}`,'error')
|
||||
const bd = detectBanReason(e)
|
||||
if (bd.status) {
|
||||
banned.status = true; banned.reason = bd.reason.substring(0,200)
|
||||
void this.handleImmediateBanAlert(account.email, banned.reason)
|
||||
}
|
||||
errors.push(formatFullErr('mobile', e)); return null
|
||||
})
|
||||
if (mobileResult) {
|
||||
mobileInitial = mobileResult.initialPoints
|
||||
mobileCollected = mobileResult.collectedPoints
|
||||
}
|
||||
this.isMobileRunning = false
|
||||
} else {
|
||||
const why = banned.status ? 'banned status' : 'compromised status'
|
||||
log(true, 'TASK', `Skipping mobile flow for ${account.email} due to ${why}`, 'warn')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const accountEnd = Date.now()
|
||||
const durationMs = accountEnd - accountStart
|
||||
const totalCollected = desktopCollected + mobileCollected
|
||||
// Correct initial points (previous version double counted desktop+mobile baselines)
|
||||
// Strategy: pick the lowest non-zero baseline (desktopInitial or mobileInitial) as true start.
|
||||
// Sequential flow: desktopInitial < mobileInitial after gain -> min = original baseline.
|
||||
// Parallel flow: both baselines equal -> min is fine.
|
||||
const baselines: number[] = []
|
||||
if (desktopInitial) baselines.push(desktopInitial)
|
||||
if (mobileInitial) baselines.push(mobileInitial)
|
||||
let initialTotal = 0
|
||||
if (baselines.length === 1) initialTotal = baselines[0]!
|
||||
else if (baselines.length === 2) initialTotal = Math.min(baselines[0]!, baselines[1]!)
|
||||
// Fallback if both missing
|
||||
if (initialTotal === 0 && (desktopInitial || mobileInitial)) initialTotal = desktopInitial || mobileInitial || 0
|
||||
const endTotal = initialTotal + totalCollected
|
||||
this.accountSummaries.push({
|
||||
email: account.email,
|
||||
durationMs,
|
||||
desktopCollected,
|
||||
mobileCollected,
|
||||
totalCollected,
|
||||
initialTotal,
|
||||
endTotal,
|
||||
errors,
|
||||
banned
|
||||
})
|
||||
|
||||
if (banned.status) {
|
||||
this.bannedTriggered = { email: account.email, reason: banned.reason }
|
||||
// Enter global standby: do not proceed to next accounts
|
||||
this.globalStandby = { active: true, reason: `banned:${banned.reason}` }
|
||||
await this.sendGlobalSecurityStandbyAlert(account.email, `Ban detected: ${banned.reason || 'unknown'}`)
|
||||
}
|
||||
|
||||
await log('main', 'MAIN-WORKER', `Completed tasks for account ${account.email}`, 'log', 'green')
|
||||
}
|
||||
|
||||
log('MAIN-PRIMARY', 'Completed tasks for ALL accounts')
|
||||
log('MAIN-PRIMARY', 'All workers destroyed!')
|
||||
process.exit(0)
|
||||
await log(this.isMobile, 'MAIN-PRIMARY', 'Completed tasks for ALL accounts', 'log', 'green')
|
||||
// Extra diagnostic summary when verbose
|
||||
if (process.env.DEBUG_REWARDS_VERBOSE === '1') {
|
||||
for (const summary of this.accountSummaries) {
|
||||
log('main','SUMMARY-DEBUG',`Account ${summary.email} collected D:${summary.desktopCollected} M:${summary.mobileCollected} TOTAL:${summary.totalCollected} ERRORS:${summary.errors.length ? summary.errors.join(';') : 'none'}`)
|
||||
}
|
||||
}
|
||||
// If any account is flagged compromised, do NOT exit; keep the process alive so the browser stays open
|
||||
if (this.compromisedModeActive || this.globalStandby.active) {
|
||||
log('main','SECURITY','Compromised or banned detected. Global standby engaged: we will NOT proceed to other accounts until resolved. Keeping process alive. Press CTRL+C to exit when done.','warn','yellow')
|
||||
const standbyInterval = setInterval(() => {
|
||||
log('main','SECURITY','Still in standby: session(s) held open for manual recovery / review...','warn','yellow')
|
||||
}, 5 * 60 * 1000)
|
||||
|
||||
// Cleanup on process exit
|
||||
process.once('SIGINT', () => { clearInterval(standbyInterval); process.exit(0) })
|
||||
process.once('SIGTERM', () => { clearInterval(standbyInterval); process.exit(0) })
|
||||
return
|
||||
}
|
||||
// If in worker mode (clusters>1) send summaries to primary
|
||||
if (this.config.clusters > 1 && !cluster.isPrimary) {
|
||||
if (process.send) {
|
||||
process.send({ type: 'summary', data: this.accountSummaries })
|
||||
}
|
||||
} else {
|
||||
// Single process mode
|
||||
}
|
||||
process.exit()
|
||||
}
|
||||
|
||||
/** Send immediate ban alert if configured. */
|
||||
private async handleImmediateBanAlert(email: string, reason: string): Promise<void> {
|
||||
try {
|
||||
const h = this.config?.humanization
|
||||
if (!h || h.immediateBanAlert === false) return
|
||||
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
|
||||
await ConclusionWebhook(
|
||||
this.config,
|
||||
'🚫 Ban Detected',
|
||||
`**Account:** ${email}\n**Reason:** ${reason || 'detected by heuristics'}`,
|
||||
undefined,
|
||||
DISCORD.COLOR_RED
|
||||
)
|
||||
} catch (e) {
|
||||
log('main','ALERT',`Failed to send ban alert: ${e instanceof Error ? e.message : e}`,'warn')
|
||||
}
|
||||
}
|
||||
|
||||
/** Compute milliseconds to wait until within one of the allowed windows (HH:mm-HH:mm). Returns 0 if already inside. */
|
||||
private computeWaitForAllowedWindow(windows: string[]): number {
|
||||
const now = new Date()
|
||||
const minsNow = now.getHours() * 60 + now.getMinutes()
|
||||
let nextStartMins: number | null = null
|
||||
for (const w of windows) {
|
||||
const [start, end] = w.split('-')
|
||||
if (!start || !end) continue
|
||||
const pStart = start.split(':').map(v=>parseInt(v,10))
|
||||
const pEnd = end.split(':').map(v=>parseInt(v,10))
|
||||
if (pStart.length !== 2 || pEnd.length !== 2) continue
|
||||
const sh = pStart[0]!, sm = pStart[1]!
|
||||
const eh = pEnd[0]!, em = pEnd[1]!
|
||||
if ([sh,sm,eh,em].some(n=>Number.isNaN(n))) continue
|
||||
const s = sh*60 + sm
|
||||
const e = eh*60 + em
|
||||
if (s <= e) {
|
||||
// same-day window
|
||||
if (minsNow >= s && minsNow <= e) return 0
|
||||
if (minsNow < s) nextStartMins = Math.min(nextStartMins ?? s, s)
|
||||
} else {
|
||||
// wraps past midnight (e.g., 22:00-02:00)
|
||||
if (minsNow >= s || minsNow <= e) return 0
|
||||
// next start today is s
|
||||
nextStartMins = Math.min(nextStartMins ?? s, s)
|
||||
}
|
||||
}
|
||||
const msPerMin = 60*1000
|
||||
if (nextStartMins != null) {
|
||||
const targetTodayMs = (nextStartMins - minsNow) * msPerMin
|
||||
return targetTodayMs > 0 ? targetTodayMs : (24*60 + nextStartMins - minsNow) * msPerMin
|
||||
}
|
||||
// No valid windows parsed -> do not block
|
||||
return 0
|
||||
}
|
||||
|
||||
// Desktop
|
||||
async Desktop(account: Account) {
|
||||
const browser = await this.browserFactory.createBrowser(account.email, account.proxy, false)
|
||||
const page = await browser.newPage()
|
||||
let pages = await browser.pages()
|
||||
log(false,'FLOW','Desktop() invoked')
|
||||
const browser = await this.browserFactory.createBrowser(account.proxy, account.email)
|
||||
this.homePage = await browser.newPage()
|
||||
|
||||
// If for some reason the browser initializes with more than 2 pages, close these
|
||||
while (pages.length > 2) {
|
||||
await pages[0]?.close()
|
||||
pages = await browser.pages()
|
||||
log(this.isMobile, 'MAIN', 'Starting browser')
|
||||
|
||||
// Login into MS Rewards, then optionally stop if compromised
|
||||
await this.login.login(this.homePage, account.email, account.password, account.totp)
|
||||
|
||||
if (this.compromisedModeActive) {
|
||||
// User wants the page to remain open for manual recovery. Do not proceed to tasks.
|
||||
const reason = this.compromisedReason || 'security-issue'
|
||||
log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving the browser open and skipping all activities for ${account.email}.`, 'warn', 'yellow')
|
||||
try {
|
||||
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
|
||||
await ConclusionWebhook(
|
||||
this.config,
|
||||
'🔐 Security Alert (Post-Login)',
|
||||
`**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving browser open; skipping tasks`,
|
||||
undefined,
|
||||
0xFFAA00
|
||||
)
|
||||
} catch {/* ignore */}
|
||||
// Save session for convenience, but do not close the browser
|
||||
try {
|
||||
await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile)
|
||||
} catch (e) {
|
||||
log(this.isMobile, 'SECURITY', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
|
||||
}
|
||||
return { initialPoints: 0, collectedPoints: 0 }
|
||||
}
|
||||
|
||||
// Log into proxy
|
||||
await page.authenticate({ username: account.proxy.username, password: account.proxy.password })
|
||||
await this.browser.func.goHome(this.homePage)
|
||||
|
||||
log('MAIN', 'Starting DESKTOP browser')
|
||||
const data = await this.browser.func.getDashboardData()
|
||||
|
||||
// Login into MS Rewards
|
||||
await this.login.login(page, account.email, account.password)
|
||||
this.pointsInitial = data.userStatus.availablePoints
|
||||
const initial = this.pointsInitial
|
||||
|
||||
const wentHome = await this.browser.func.goHome(page)
|
||||
if (!wentHome) {
|
||||
throw log('MAIN', 'Unable to get dashboard page', 'error')
|
||||
log(this.isMobile, 'MAIN-POINTS', `Current point count: ${this.pointsInitial}`)
|
||||
|
||||
const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints()
|
||||
|
||||
// Tally all the desktop points
|
||||
this.pointsCanCollect = browserEnarablePoints.dailySetPoints +
|
||||
browserEnarablePoints.desktopSearchPoints
|
||||
+ browserEnarablePoints.morePromotionsPoints
|
||||
|
||||
log(this.isMobile, 'MAIN-POINTS', `You can earn ${this.pointsCanCollect} points today`)
|
||||
|
||||
if (this.pointsCanCollect === 0) {
|
||||
// Extra diagnostic breakdown so users know WHY it's zero
|
||||
log(this.isMobile, 'MAIN-POINTS', `Breakdown (desktop): dailySet=${browserEnarablePoints.dailySetPoints} search=${browserEnarablePoints.desktopSearchPoints} promotions=${browserEnarablePoints.morePromotionsPoints}`)
|
||||
log(this.isMobile, 'MAIN-POINTS', 'All desktop earnable buckets are zero. This usually means: tasks already completed today OR the daily reset has not happened yet for your time zone. If you still want to force run activities set execution.runOnZeroPoints=true in config.', 'log', 'yellow')
|
||||
}
|
||||
|
||||
const data = await this.browser.func.getDashboardData(page)
|
||||
log('MAIN-POINTS', `Current point count: ${data.userStatus.availablePoints}`)
|
||||
|
||||
const earnablePoints = await this.browser.func.getEarnablePoints(data)
|
||||
this.collectedPoints = earnablePoints
|
||||
log('MAIN-POINTS', `You can earn ${earnablePoints} points today`)
|
||||
|
||||
// If runOnZeroPoints is false and 0 points to earn, don't continue
|
||||
if (!this.config.runOnZeroPoints && this.collectedPoints === 0) {
|
||||
log('MAIN', 'No points to earn and "runOnZeroPoints" is set to "false", stopping')
|
||||
if (!this.config.runOnZeroPoints && this.pointsCanCollect === 0) {
|
||||
log(this.isMobile, 'MAIN', 'No points to earn and "runOnZeroPoints" is set to "false", stopping!', 'log', 'yellow')
|
||||
|
||||
// Close desktop browser
|
||||
return await browser.close()
|
||||
await this.browser.func.closeBrowser(browser, account.email)
|
||||
return
|
||||
}
|
||||
|
||||
// Open a new tab to where the tasks are going to be completed
|
||||
const workerPage = await browser.newPage()
|
||||
|
||||
// Go to homepage on worker page
|
||||
await this.browser.func.goHome(workerPage)
|
||||
|
||||
// Complete daily set
|
||||
if (this.config.workers.doDailySet) {
|
||||
await this.workers.doDailySet(page, data)
|
||||
await this.workers.doDailySet(workerPage, data)
|
||||
}
|
||||
|
||||
// Complete more promotions
|
||||
if (this.config.workers.doMorePromotions) {
|
||||
await this.workers.doMorePromotions(page, data)
|
||||
await this.workers.doMorePromotions(workerPage, data)
|
||||
}
|
||||
|
||||
// Complete punch cards
|
||||
if (this.config.workers.doPunchCards) {
|
||||
await this.workers.doPunchCard(page, data)
|
||||
await this.workers.doPunchCard(workerPage, data)
|
||||
}
|
||||
|
||||
// Do desktop searches
|
||||
if (this.config.workers.doDesktopSearch) {
|
||||
await this.activities.doSearch(page, data, false)
|
||||
await this.activities.doSearch(workerPage, data)
|
||||
}
|
||||
|
||||
// Save cookies
|
||||
await saveSessionData(this.config.sessionPath, browser, account.email, this.isMobile)
|
||||
|
||||
// Fetch points BEFORE closing (avoid page closed reload error)
|
||||
const after = await this.browser.func.getCurrentPoints().catch(()=>initial)
|
||||
// Close desktop browser
|
||||
await browser.close()
|
||||
await this.browser.func.closeBrowser(browser, account.email)
|
||||
return {
|
||||
initialPoints: initial,
|
||||
collectedPoints: (after - initial) || 0
|
||||
}
|
||||
}
|
||||
|
||||
// Mobile
|
||||
async Mobile(account: Account) {
|
||||
const browser = await this.browserFactory.createBrowser(account.email, account.proxy, true)
|
||||
const page = await browser.newPage()
|
||||
let pages = await browser.pages()
|
||||
log(true,'FLOW','Mobile() invoked')
|
||||
const browser = await this.browserFactory.createBrowser(account.proxy, account.email)
|
||||
this.homePage = await browser.newPage()
|
||||
|
||||
// If for some reason the browser initializes with more than 2 pages, close these
|
||||
while (pages.length > 2) {
|
||||
await pages[0]?.close()
|
||||
pages = await browser.pages()
|
||||
log(this.isMobile, 'MAIN', 'Starting browser')
|
||||
|
||||
// Login into MS Rewards, then respect compromised mode
|
||||
await this.login.login(this.homePage, account.email, account.password, account.totp)
|
||||
if (this.compromisedModeActive) {
|
||||
const reason = this.compromisedReason || 'security-issue'
|
||||
log(this.isMobile, 'SECURITY', `Account flagged as compromised (${reason}). Leaving mobile browser open and skipping mobile activities for ${account.email}.`, 'warn', 'yellow')
|
||||
try {
|
||||
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
|
||||
await ConclusionWebhook(
|
||||
this.config,
|
||||
'🔐 Security Alert (Mobile)',
|
||||
`**Account:** ${account.email}\n**Reason:** ${reason}\n**Action:** Leaving mobile browser open; skipping tasks`,
|
||||
undefined,
|
||||
0xFFAA00
|
||||
)
|
||||
} catch {/* ignore */}
|
||||
try {
|
||||
await saveSessionData(this.config.sessionPath, this.homePage.context(), account.email, this.isMobile)
|
||||
} catch (e) {
|
||||
log(this.isMobile, 'SECURITY', `Failed to save session: ${e instanceof Error ? e.message : String(e)}`, 'warn')
|
||||
}
|
||||
return { initialPoints: 0, collectedPoints: 0 }
|
||||
}
|
||||
// Log into proxy
|
||||
await page.authenticate({ username: account.proxy.username, password: account.proxy.password })
|
||||
this.accessToken = await this.login.getMobileAccessToken(this.homePage, account.email)
|
||||
|
||||
log('MAIN', 'Starting MOBILE browser')
|
||||
await this.browser.func.goHome(this.homePage)
|
||||
|
||||
// Login into MS Rewards
|
||||
await this.login.login(page, account.email, account.password)
|
||||
const data = await this.browser.func.getDashboardData()
|
||||
const initialPoints = data.userStatus.availablePoints || this.pointsInitial || 0
|
||||
|
||||
await this.browser.func.goHome(page)
|
||||
const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints()
|
||||
const appEarnablePoints = await this.browser.func.getAppEarnablePoints(this.accessToken)
|
||||
|
||||
const data = await this.browser.func.getDashboardData(page)
|
||||
this.pointsCanCollect = browserEnarablePoints.mobileSearchPoints + appEarnablePoints.totalEarnablePoints
|
||||
|
||||
// If no mobile searches data found, stop (Does not exist on new accounts)
|
||||
if (!data.userStatus.counters.mobileSearch) {
|
||||
log('MAIN', 'No mobile searches found, stopping')
|
||||
log(this.isMobile, 'MAIN-POINTS', `You can earn ${this.pointsCanCollect} points today (Browser: ${browserEnarablePoints.mobileSearchPoints} points, App: ${appEarnablePoints.totalEarnablePoints} points)`)
|
||||
|
||||
if (this.pointsCanCollect === 0) {
|
||||
log(this.isMobile, 'MAIN-POINTS', `Breakdown (mobile): browserSearch=${browserEnarablePoints.mobileSearchPoints} appTotal=${appEarnablePoints.totalEarnablePoints}`)
|
||||
log(this.isMobile, 'MAIN-POINTS', 'All mobile earnable buckets are zero. Causes: mobile searches already maxed, daily set finished, or daily rollover not reached yet. You can force execution by setting execution.runOnZeroPoints=true.', 'log', 'yellow')
|
||||
}
|
||||
|
||||
// If runOnZeroPoints is false and 0 points to earn, don't continue
|
||||
if (!this.config.runOnZeroPoints && this.pointsCanCollect === 0) {
|
||||
log(this.isMobile, 'MAIN', 'No points to earn and "runOnZeroPoints" is set to "false", stopping!', 'log', 'yellow')
|
||||
|
||||
// Close mobile browser
|
||||
return await browser.close()
|
||||
await this.browser.func.closeBrowser(browser, account.email)
|
||||
return {
|
||||
initialPoints: initialPoints,
|
||||
collectedPoints: 0
|
||||
}
|
||||
}
|
||||
// Do daily check in
|
||||
if (this.config.workers.doDailyCheckIn) {
|
||||
await this.activities.doDailyCheckIn(this.accessToken, data)
|
||||
}
|
||||
|
||||
// Do read to earn
|
||||
if (this.config.workers.doReadToEarn) {
|
||||
await this.activities.doReadToEarn(this.accessToken, data)
|
||||
}
|
||||
|
||||
// Do mobile searches
|
||||
if (this.config.workers.doMobileSearch) {
|
||||
await this.activities.doSearch(page, data, true)
|
||||
// If no mobile searches data found, stop (Does not always exist on new accounts)
|
||||
if (data.userStatus.counters.mobileSearch) {
|
||||
// Open a new tab to where the tasks are going to be completed
|
||||
const workerPage = await browser.newPage()
|
||||
|
||||
// Go to homepage on worker page
|
||||
await this.browser.func.goHome(workerPage)
|
||||
|
||||
await this.activities.doSearch(workerPage, data)
|
||||
|
||||
// Fetch current search points
|
||||
const mobileSearchPoints = (await this.browser.func.getSearchPoints()).mobileSearch?.[0]
|
||||
|
||||
if (mobileSearchPoints && (mobileSearchPoints.pointProgressMax - mobileSearchPoints.pointProgress) > 0) {
|
||||
// Increment retry count
|
||||
this.mobileRetryAttempts++
|
||||
}
|
||||
|
||||
// Exit if retries are exhausted
|
||||
if (this.mobileRetryAttempts > this.config.searchSettings.retryMobileSearchAmount) {
|
||||
log(this.isMobile, 'MAIN', `Max retry limit of ${this.config.searchSettings.retryMobileSearchAmount} reached. Exiting retry loop`, 'warn')
|
||||
} else if (this.mobileRetryAttempts !== 0) {
|
||||
log(this.isMobile, 'MAIN', `Attempt ${this.mobileRetryAttempts}/${this.config.searchSettings.retryMobileSearchAmount}: Unable to complete mobile searches, bad User-Agent? Increase search delay? Retrying...`, 'log', 'yellow')
|
||||
|
||||
// Close mobile browser
|
||||
await this.browser.func.closeBrowser(browser, account.email)
|
||||
|
||||
// Create a new browser and try
|
||||
await this.Mobile(account)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
log(this.isMobile, 'MAIN', 'Unable to fetch search points, your account is most likely too "new" for this! Try again later!', 'warn')
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch new points
|
||||
const earnablePoints = await this.browser.func.getEarnablePoints(data, page)
|
||||
const afterPointAmount = await this.browser.func.getCurrentPoints()
|
||||
|
||||
// If the new earnable is 0, means we got all the points, else retract
|
||||
this.collectedPoints = earnablePoints === 0 ? this.collectedPoints : (this.collectedPoints - earnablePoints)
|
||||
log('MAIN-POINTS', `The script collected ${this.collectedPoints} points today`)
|
||||
log(this.isMobile, 'MAIN-POINTS', `The script collected ${afterPointAmount - initialPoints} points today`)
|
||||
|
||||
// Close mobile browser
|
||||
await browser.close()
|
||||
await this.browser.func.closeBrowser(browser, account.email)
|
||||
return {
|
||||
initialPoints: initialPoints,
|
||||
collectedPoints: (afterPointAmount - initialPoints) || 0
|
||||
}
|
||||
}
|
||||
|
||||
private async sendConclusion(summaries: AccountSummary[]) {
|
||||
const { ConclusionWebhookEnhanced } = await import('./util/ConclusionWebhook')
|
||||
const cfg = this.config
|
||||
|
||||
const conclusionWebhookEnabled = !!(cfg.conclusionWebhook && cfg.conclusionWebhook.enabled)
|
||||
const ntfyEnabled = !!(cfg.ntfy && cfg.ntfy.enabled)
|
||||
const webhookEnabled = !!(cfg.webhook && cfg.webhook.enabled)
|
||||
|
||||
const totalAccounts = summaries.length
|
||||
if (totalAccounts === 0) return
|
||||
|
||||
let totalCollected = 0
|
||||
let totalInitial = 0
|
||||
let totalEnd = 0
|
||||
let totalDuration = 0
|
||||
let accountsWithErrors = 0
|
||||
let accountsBanned = 0
|
||||
let successes = 0
|
||||
|
||||
// Calculate summary statistics
|
||||
for (const s of summaries) {
|
||||
totalCollected += s.totalCollected
|
||||
totalInitial += s.initialTotal
|
||||
totalEnd += s.endTotal
|
||||
totalDuration += s.durationMs
|
||||
if (s.banned?.status) accountsBanned++
|
||||
if (s.errors.length) accountsWithErrors++
|
||||
if (!s.banned?.status && !s.errors.length) successes++
|
||||
}
|
||||
|
||||
const avgDuration = totalDuration / totalAccounts
|
||||
const avgPointsPerAccount = Math.round(totalCollected / totalAccounts)
|
||||
|
||||
// Read package version
|
||||
let version = 'unknown'
|
||||
try {
|
||||
const pkgPath = path.join(process.cwd(), 'package.json')
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
const raw = fs.readFileSync(pkgPath, 'utf-8')
|
||||
const pkg = JSON.parse(raw)
|
||||
version = pkg.version || version
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Send enhanced webhook
|
||||
if (conclusionWebhookEnabled || ntfyEnabled || webhookEnabled) {
|
||||
await ConclusionWebhookEnhanced(cfg, {
|
||||
version,
|
||||
runId: this.runId,
|
||||
totalAccounts,
|
||||
successes,
|
||||
accountsWithErrors,
|
||||
accountsBanned,
|
||||
totalCollected,
|
||||
totalInitial,
|
||||
totalEnd,
|
||||
avgPointsPerAccount,
|
||||
totalDuration,
|
||||
avgDuration,
|
||||
summaries
|
||||
})
|
||||
}
|
||||
|
||||
// Write local JSON report
|
||||
try {
|
||||
const fs = await import('fs')
|
||||
const path = await import('path')
|
||||
const now = new Date()
|
||||
const day = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')}`
|
||||
const baseDir = path.join(process.cwd(), 'reports', day)
|
||||
if (!fs.existsSync(baseDir)) fs.mkdirSync(baseDir, { recursive: true })
|
||||
const file = path.join(baseDir, `summary_${this.runId}.json`)
|
||||
const payload = {
|
||||
runId: this.runId,
|
||||
timestamp: now.toISOString(),
|
||||
totals: { totalCollected, totalInitial, totalEnd, totalDuration, totalAccounts, accountsWithErrors },
|
||||
perAccount: summaries
|
||||
}
|
||||
fs.writeFileSync(file, JSON.stringify(payload, null, 2), 'utf-8')
|
||||
log('main','REPORT',`Saved report to ${file}`)
|
||||
} catch (e) {
|
||||
log('main','REPORT',`Failed to save report: ${e instanceof Error ? e.message : e}`,'warn')
|
||||
}
|
||||
|
||||
|
||||
// Optional community notice (shown randomly in ~15% of successful runs)
|
||||
if (Math.random() > 0.85 && successes > 0 && accountsWithErrors === 0) {
|
||||
log('main','INFO','Want faster updates & enhanced anti-detection? Community builds available: https://discord.gg/kn3695Kx32')
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Public entry-point to engage global security standby from other modules (idempotent). */
|
||||
public async engageGlobalStandby(reason: string, email?: string): Promise<void> {
|
||||
try {
|
||||
if (this.globalStandby.active) return
|
||||
this.globalStandby = { active: true, reason }
|
||||
const who = email || this.currentAccountEmail || 'unknown'
|
||||
await this.sendGlobalSecurityStandbyAlert(who, reason)
|
||||
} catch {/* ignore */}
|
||||
}
|
||||
|
||||
/** Send a strong alert to all channels and mention @everyone when entering global security standby. */
|
||||
private async sendGlobalSecurityStandbyAlert(email: string, reason: string): Promise<void> {
|
||||
try {
|
||||
const { ConclusionWebhook } = await import('./util/ConclusionWebhook')
|
||||
await ConclusionWebhook(
|
||||
this.config,
|
||||
'🚨 Global Security Standby Engaged',
|
||||
`@everyone\n\n**Account:** ${email}\n**Reason:** ${reason}\n**Action:** Pausing all further accounts. We will not proceed until this is resolved.`,
|
||||
undefined,
|
||||
DISCORD.COLOR_RED
|
||||
)
|
||||
} catch (e) {
|
||||
log('main','ALERT',`Failed to send standby alert: ${e instanceof Error ? e.message : e}`,'warn')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bot = new MicrosoftRewardsBot()
|
||||
interface AccountSummary {
|
||||
email: string
|
||||
durationMs: number
|
||||
desktopCollected: number
|
||||
mobileCollected: number
|
||||
totalCollected: number
|
||||
initialTotal: number
|
||||
endTotal: number
|
||||
errors: string[]
|
||||
banned?: { status: boolean; reason: string }
|
||||
}
|
||||
|
||||
// Initialize accounts first and then start the bot
|
||||
bot.initialize().then(() => {
|
||||
bot.run()
|
||||
})
|
||||
function shortErr(e: unknown): string {
|
||||
if (e == null) return 'unknown'
|
||||
if (e instanceof Error) return e.message.substring(0, 120)
|
||||
const s = String(e)
|
||||
return s.substring(0, 120)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const rewardsBot = new MicrosoftRewardsBot(false)
|
||||
|
||||
const crashState = { restarts: 0 }
|
||||
const config = rewardsBot.config
|
||||
|
||||
const attachHandlers = () => {
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
log('main','FATAL','UnhandledRejection: ' + (reason instanceof Error ? reason.message : String(reason)), 'error')
|
||||
gracefulExit(1)
|
||||
})
|
||||
process.on('uncaughtException', (err) => {
|
||||
log('main','FATAL','UncaughtException: ' + err.message, 'error')
|
||||
gracefulExit(1)
|
||||
})
|
||||
process.on('SIGTERM', () => gracefulExit(0))
|
||||
process.on('SIGINT', () => gracefulExit(0))
|
||||
}
|
||||
|
||||
const gracefulExit = (code: number) => {
|
||||
if (config?.crashRecovery?.autoRestart && code !== 0) {
|
||||
const max = config.crashRecovery.maxRestarts ?? 2
|
||||
if (crashState.restarts < max) {
|
||||
const backoff = (config.crashRecovery.backoffBaseMs ?? 2000) * (crashState.restarts + 1)
|
||||
log('main','CRASH-RECOVERY',`Scheduling restart in ${backoff}ms (attempt ${crashState.restarts + 1}/${max})`, 'warn','yellow')
|
||||
setTimeout(() => {
|
||||
crashState.restarts++
|
||||
bootstrap()
|
||||
}, backoff)
|
||||
return
|
||||
}
|
||||
}
|
||||
process.exit(code)
|
||||
}
|
||||
|
||||
const bootstrap = async () => {
|
||||
try {
|
||||
await rewardsBot.initialize()
|
||||
await rewardsBot.run()
|
||||
} catch (e) {
|
||||
log('main','MAIN-ERROR','Fatal during run: ' + (e instanceof Error ? e.message : e),'error')
|
||||
gracefulExit(1)
|
||||
}
|
||||
}
|
||||
|
||||
attachHandlers()
|
||||
await bootstrap()
|
||||
}
|
||||
|
||||
// Start the bots
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
log('main', 'MAIN-ERROR', `Error running bots: ${error}`, 'error')
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
export interface Account {
|
||||
/** Enable/disable this account (if false, account will be skipped during execution) */
|
||||
enabled?: boolean;
|
||||
email: string;
|
||||
password: string;
|
||||
/** Optional TOTP secret in Base32 (e.g., from Microsoft Authenticator setup) */
|
||||
totp?: string;
|
||||
/** Optional recovery email used to verify masked address on Microsoft login screens */
|
||||
recoveryEmail?: string;
|
||||
proxy: AccountProxy;
|
||||
}
|
||||
|
||||
export interface AccountProxy {
|
||||
proxyAxios: boolean;
|
||||
url: string;
|
||||
port: number;
|
||||
password: string;
|
||||
|
||||
21
src/interface/ActivityHandler.ts
Normal file
21
src/interface/ActivityHandler.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { MorePromotion, PromotionalItem } from './DashboardData'
|
||||
import type { Page } from 'playwright'
|
||||
|
||||
/**
|
||||
* Activity handler contract for solving a single dashboard activity.
|
||||
* Implementations should be stateless (or hold only a reference to the bot)
|
||||
* and perform all required steps on the provided page.
|
||||
*/
|
||||
export interface ActivityHandler {
|
||||
/** Optional identifier for diagnostics */
|
||||
id?: string
|
||||
/**
|
||||
* Return true if this handler knows how to process the given activity.
|
||||
*/
|
||||
canHandle(activity: MorePromotion | PromotionalItem): boolean
|
||||
/**
|
||||
* Execute the activity on the provided page. The page is already
|
||||
* navigated to the activity tab/window by the caller.
|
||||
*/
|
||||
run(page: Page, activity: MorePromotion | PromotionalItem): Promise<void>
|
||||
}
|
||||
226
src/interface/AppUserData.ts
Normal file
226
src/interface/AppUserData.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
export interface AppUserData {
|
||||
response: Response;
|
||||
correlationId: string;
|
||||
code: number;
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
profile: Profile;
|
||||
balance: number;
|
||||
counters: null;
|
||||
promotions: Promotion[];
|
||||
catalog: null;
|
||||
goal_item: GoalItem;
|
||||
activities: null;
|
||||
cashback: null;
|
||||
orders: Order[];
|
||||
rebateProfile: null;
|
||||
rebatePayouts: null;
|
||||
giveProfile: GiveProfile;
|
||||
autoRedeemProfile: null;
|
||||
autoRedeemItem: null;
|
||||
thirdPartyProfile: null;
|
||||
notifications: null;
|
||||
waitlist: null;
|
||||
autoOpenFlyout: null;
|
||||
coupons: null;
|
||||
recommendedAffordableCatalog: null;
|
||||
}
|
||||
|
||||
export interface GiveProfile {
|
||||
give_user: string;
|
||||
give_organization: { [key: string]: GiveOrganization | null };
|
||||
first_give_optin: string;
|
||||
last_give_optout: string;
|
||||
give_lifetime_balance: string;
|
||||
give_lifetime_donation_balance: string;
|
||||
give_balance: string;
|
||||
form: null;
|
||||
}
|
||||
|
||||
export interface GiveOrganization {
|
||||
give_organization_donation_points: number;
|
||||
give_organization_donation_point_to_currency_ratio: number;
|
||||
give_organization_donation_currency: number;
|
||||
}
|
||||
|
||||
export interface GoalItem {
|
||||
name: string;
|
||||
provider: string;
|
||||
price: number;
|
||||
attributes: GoalItemAttributes;
|
||||
config: GoalItemConfig;
|
||||
}
|
||||
|
||||
export interface GoalItemAttributes {
|
||||
category: string;
|
||||
CategoryDescription: string;
|
||||
'desc.group_text': string;
|
||||
'desc.legal_text'?: string;
|
||||
'desc.sc_description': string;
|
||||
'desc.sc_title': string;
|
||||
display_order: string;
|
||||
ExtraLargeImage: string;
|
||||
group: string;
|
||||
group_image: string;
|
||||
group_sc_image: string;
|
||||
group_title: string;
|
||||
hidden?: string;
|
||||
large_image: string;
|
||||
large_sc_image: string;
|
||||
medium_image: string;
|
||||
MobileImage: string;
|
||||
original_price: string;
|
||||
Remarks?: string;
|
||||
ShortText?: string;
|
||||
showcase?: string;
|
||||
small_image: string;
|
||||
title: string;
|
||||
cimsid: string;
|
||||
user_defined_goal?: string;
|
||||
disable_bot_redemptions?: string;
|
||||
'desc.large_text'?: string;
|
||||
english_title?: string;
|
||||
etid?: string;
|
||||
sku?: string;
|
||||
coupon_discount?: string;
|
||||
}
|
||||
|
||||
export interface GoalItemConfig {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
isHidden: string;
|
||||
PointToCurrencyConversionRatio: string;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
t: Date;
|
||||
sku: string;
|
||||
item_snapshot: ItemSnapshot;
|
||||
p: number;
|
||||
s: S;
|
||||
a: A;
|
||||
child_redemption: null;
|
||||
third_party_partner: null;
|
||||
log: Log[];
|
||||
}
|
||||
|
||||
export interface A {
|
||||
form?: string;
|
||||
OrderId: string;
|
||||
CorrelationId: string;
|
||||
Channel: string;
|
||||
Language: string;
|
||||
Country: string;
|
||||
EvaluationId: string;
|
||||
provider?: string;
|
||||
referenceOrderID?: string;
|
||||
externalRefID?: string;
|
||||
denomination?: string;
|
||||
rewardName?: string;
|
||||
sendEmail?: string;
|
||||
status?: string;
|
||||
createdAt?: Date;
|
||||
bal_before_deduct?: string;
|
||||
bal_after_deduct?: string;
|
||||
}
|
||||
|
||||
export interface ItemSnapshot {
|
||||
name: string;
|
||||
provider: string;
|
||||
price: number;
|
||||
attributes: GoalItemAttributes;
|
||||
config: ItemSnapshotConfig;
|
||||
}
|
||||
|
||||
export interface ItemSnapshotConfig {
|
||||
amount: string;
|
||||
countryCode: string;
|
||||
currencyCode: string;
|
||||
sku: string;
|
||||
}
|
||||
|
||||
export interface Log {
|
||||
time: Date;
|
||||
from: From;
|
||||
to: S;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export enum From {
|
||||
Created = 'Created',
|
||||
RiskApproved = 'RiskApproved',
|
||||
RiskReview = 'RiskReview'
|
||||
}
|
||||
|
||||
export enum S {
|
||||
Cancelled = 'Cancelled',
|
||||
RiskApproved = 'RiskApproved',
|
||||
RiskReview = 'RiskReview',
|
||||
Shipped = 'Shipped'
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
ruid: string;
|
||||
attributes: ProfileAttributes;
|
||||
offline_attributes: OfflineAttributes;
|
||||
}
|
||||
|
||||
export interface ProfileAttributes {
|
||||
publisher: string;
|
||||
publisher_upd: Date;
|
||||
creative: string;
|
||||
creative_upd: Date;
|
||||
program: string;
|
||||
program_upd: Date;
|
||||
country: string;
|
||||
country_upd: Date;
|
||||
referrerhash: string;
|
||||
referrerhash_upd: Date;
|
||||
optout_upd: Date;
|
||||
language: string;
|
||||
language_upd: Date;
|
||||
target: string;
|
||||
target_upd: Date;
|
||||
created: Date;
|
||||
created_upd: Date;
|
||||
epuid: string;
|
||||
epuid_upd: Date;
|
||||
goal: string;
|
||||
goal_upd: Date;
|
||||
waitlistattributes: string;
|
||||
waitlistattributes_upd: Date;
|
||||
serpbotscore_upd: Date;
|
||||
iscashbackeligible: string;
|
||||
cbedc: string;
|
||||
rlscpct_upd: Date;
|
||||
give_user: string;
|
||||
rebcpc_upd: Date;
|
||||
SerpBotScore_upd: Date;
|
||||
AdsBotScore_upd: Date;
|
||||
dbs_upd: Date;
|
||||
rbs: string;
|
||||
rbs_upd: Date;
|
||||
iris_segmentation: string;
|
||||
iris_segmentation_upd: Date;
|
||||
}
|
||||
|
||||
export interface OfflineAttributes {
|
||||
}
|
||||
|
||||
export interface Promotion {
|
||||
name: string;
|
||||
priority: number;
|
||||
attributes: { [key: string]: string };
|
||||
tags: Tag[];
|
||||
}
|
||||
|
||||
export enum Tag {
|
||||
AllowTrialUser = 'allow_trial_user',
|
||||
ExcludeGivePcparent = 'exclude_give_pcparent',
|
||||
ExcludeGlobalConfig = 'exclude_global_config',
|
||||
ExcludeHidden = 'exclude_hidden',
|
||||
LOCString = 'locString',
|
||||
NonGlobalConfig = 'non_global_config'
|
||||
}
|
||||
@@ -2,34 +2,173 @@ export interface Config {
|
||||
baseURL: string;
|
||||
sessionPath: string;
|
||||
headless: boolean;
|
||||
browser?: ConfigBrowser; // Optional nested browser config
|
||||
fingerprinting?: ConfigFingerprinting; // Optional nested fingerprinting config
|
||||
parallel: boolean;
|
||||
runOnZeroPoints: boolean;
|
||||
clusters: number;
|
||||
workers: Workers;
|
||||
searchSettings: SearchSettings;
|
||||
webhook: Webhook;
|
||||
saveFingerprint: ConfigSaveFingerprint;
|
||||
workers: ConfigWorkers;
|
||||
searchOnBingLocalQueries: boolean;
|
||||
globalTimeout: number | string;
|
||||
searchSettings: ConfigSearchSettings;
|
||||
humanization?: ConfigHumanization; // Anti-ban humanization controls
|
||||
retryPolicy?: ConfigRetryPolicy; // Global retry/backoff policy
|
||||
jobState?: ConfigJobState; // Persistence of per-activity checkpoints
|
||||
logExcludeFunc: string[];
|
||||
webhookLogExcludeFunc: string[];
|
||||
logging?: ConfigLogging; // Preserve original logging object (for live webhook settings)
|
||||
proxy: ConfigProxy;
|
||||
webhook: ConfigWebhook;
|
||||
conclusionWebhook?: ConfigWebhook; // Optional secondary webhook for final summary
|
||||
ntfy: ConfigNtfy;
|
||||
vacation?: ConfigVacation; // Optional monthly contiguous off-days
|
||||
crashRecovery?: ConfigCrashRecovery; // Automatic restart / graceful shutdown
|
||||
riskManagement?: ConfigRiskManagement; // NEW: Risk-aware throttling and ban prediction
|
||||
dryRun?: boolean; // NEW: Dry-run mode (simulate without executing)
|
||||
queryDiversity?: ConfigQueryDiversity; // NEW: Multi-source query generation
|
||||
}
|
||||
|
||||
export interface SearchSettings {
|
||||
export interface ConfigSaveFingerprint {
|
||||
mobile: boolean;
|
||||
desktop: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigBrowser {
|
||||
headless?: boolean;
|
||||
globalTimeout?: number | string;
|
||||
}
|
||||
|
||||
export interface ConfigFingerprinting {
|
||||
saveFingerprint?: ConfigSaveFingerprint;
|
||||
}
|
||||
|
||||
export interface ConfigSearchSettings {
|
||||
useGeoLocaleQueries: boolean;
|
||||
scrollRandomResults: boolean;
|
||||
clickRandomResults: boolean;
|
||||
searchDelay: SearchDelay;
|
||||
searchDelay: ConfigSearchDelay;
|
||||
retryMobileSearchAmount: number;
|
||||
localFallbackCount?: number; // Number of local fallback queries to sample when trends fail
|
||||
extraFallbackRetries?: number; // Additional mini-retry loops with fallback terms
|
||||
}
|
||||
|
||||
export interface SearchDelay {
|
||||
min: number;
|
||||
max: number;
|
||||
export interface ConfigSearchDelay {
|
||||
min: number | string;
|
||||
max: number | string;
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
export interface ConfigWebhook {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
username?: string; // Custom webhook username (default: "Microsoft Rewards")
|
||||
avatarUrl?: string; // Custom webhook avatar URL
|
||||
}
|
||||
|
||||
export interface Workers {
|
||||
export interface ConfigNtfy {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
topic: string;
|
||||
authToken?: string; // Optional authentication token
|
||||
}
|
||||
|
||||
export interface ConfigProxy {
|
||||
proxyGoogleTrends: boolean;
|
||||
proxyBingTerms: boolean;
|
||||
}
|
||||
|
||||
export interface ConfigVacation {
|
||||
enabled?: boolean; // default false
|
||||
minDays?: number; // default 3
|
||||
maxDays?: number; // default 5
|
||||
}
|
||||
|
||||
export interface ConfigCrashRecovery {
|
||||
autoRestart?: boolean; // Restart the root process after fatal crash
|
||||
maxRestarts?: number; // Max restart attempts (default 2)
|
||||
backoffBaseMs?: number; // Base backoff before restart (default 2000)
|
||||
restartFailedWorker?: boolean; // (future) attempt to respawn crashed worker
|
||||
restartFailedWorkerAttempts?: number; // attempts per worker (default 1)
|
||||
}
|
||||
|
||||
export interface ConfigWorkers {
|
||||
doDailySet: boolean;
|
||||
doMorePromotions: boolean;
|
||||
doPunchCards: boolean;
|
||||
doDesktopSearch: boolean;
|
||||
doMobileSearch: boolean;
|
||||
doDailyCheckIn: boolean;
|
||||
doReadToEarn: boolean;
|
||||
bundleDailySetWithSearch?: boolean; // If true, run desktop search right after Daily Set
|
||||
}
|
||||
|
||||
// Anti-ban humanization
|
||||
export interface ConfigHumanization {
|
||||
// Master toggle for Human Mode. When false, humanization is minimized.
|
||||
enabled?: boolean;
|
||||
// If true, stop processing remaining accounts after a ban is detected
|
||||
stopOnBan?: boolean;
|
||||
// If true, send an immediate webhook/NTFY alert when a ban is detected
|
||||
immediateBanAlert?: boolean;
|
||||
// Additional random waits between actions
|
||||
actionDelay?: { min: number | string; max: number | string };
|
||||
// Probability [0..1] to perform micro mouse moves per step
|
||||
gestureMoveProb?: number;
|
||||
// Probability [0..1] to perform tiny scrolls per step
|
||||
gestureScrollProb?: number;
|
||||
// Allowed execution windows (local time). Each item is "HH:mm-HH:mm".
|
||||
// If provided, runs outside these windows will be delayed until the next allowed window.
|
||||
allowedWindows?: string[];
|
||||
// Randomly skip N days per week to look more human (0-7). Default 1.
|
||||
randomOffDaysPerWeek?: number;
|
||||
}
|
||||
|
||||
// Retry/backoff policy
|
||||
export interface ConfigRetryPolicy {
|
||||
maxAttempts?: number; // default 3
|
||||
baseDelay?: number | string; // default 1000ms
|
||||
maxDelay?: number | string; // default 30s
|
||||
multiplier?: number; // default 2
|
||||
jitter?: number; // 0..1; default 0.2
|
||||
}
|
||||
|
||||
// Job state persistence
|
||||
export interface ConfigJobState {
|
||||
enabled?: boolean; // default true
|
||||
dir?: string; // base directory; defaults to <sessionPath>/job-state
|
||||
}
|
||||
|
||||
// Live logging configuration
|
||||
export interface ConfigLoggingLive {
|
||||
enabled?: boolean; // master switch for live webhook logs
|
||||
redactEmails?: boolean; // if true, redact emails in outbound logs
|
||||
}
|
||||
|
||||
export interface ConfigLogging {
|
||||
excludeFunc?: string[];
|
||||
webhookExcludeFunc?: string[];
|
||||
live?: ConfigLoggingLive;
|
||||
liveWebhookUrl?: string; // legacy/dedicated live webhook override
|
||||
redactEmails?: boolean; // legacy top-level redaction flag
|
||||
// Optional nested live.url support (already handled dynamically in Logger)
|
||||
[key: string]: unknown; // forward compatibility
|
||||
}
|
||||
|
||||
// CommunityHelp removed (privacy-first policy)
|
||||
|
||||
// NEW FEATURES: Risk Management, Query Diversity
|
||||
export interface ConfigRiskManagement {
|
||||
enabled?: boolean; // master toggle for risk-aware throttling
|
||||
autoAdjustDelays?: boolean; // automatically increase delays when risk is high
|
||||
stopOnCritical?: boolean; // halt execution if risk reaches critical level
|
||||
banPrediction?: boolean; // enable ML-style ban prediction
|
||||
riskThreshold?: number; // 0-100, pause if risk exceeds this
|
||||
}
|
||||
|
||||
export interface ConfigQueryDiversity {
|
||||
enabled?: boolean; // use multi-source query generation
|
||||
sources?: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>; // which sources to use
|
||||
maxQueriesPerSource?: number; // limit per source
|
||||
cacheMinutes?: number; // cache duration
|
||||
}
|
||||
|
||||
|
||||
@@ -351,11 +351,13 @@ export interface MorePromotion {
|
||||
legalText: string;
|
||||
legalLinkText: string;
|
||||
deviceType: string;
|
||||
exclusiveLockedFeatureType: string;
|
||||
exclusiveLockedFeatureStatus: string;
|
||||
}
|
||||
|
||||
export interface PunchCard {
|
||||
name: string;
|
||||
parentPromotion: PromotionalItem;
|
||||
parentPromotion?: PromotionalItem;
|
||||
childPromotions: PromotionalItem[];
|
||||
}
|
||||
|
||||
|
||||
9
src/interface/OAuth.ts
Normal file
9
src/interface/OAuth.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export interface OAuth {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
scope: string;
|
||||
expires_in: number;
|
||||
ext_expires_in: number;
|
||||
foci: string;
|
||||
token_type: string;
|
||||
}
|
||||
7
src/interface/Points.ts
Normal file
7
src/interface/Points.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface EarnablePoints {
|
||||
desktopSearchPoints: number
|
||||
mobileSearchPoints: number
|
||||
dailySetPoints: number
|
||||
morePromotionsPoints: number
|
||||
totalEarnablePoints: number
|
||||
}
|
||||
7
src/luxon.d.ts
vendored
Normal file
7
src/luxon.d.ts
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
/* Minimal ambient declarations to unblock TypeScript when @types/luxon is absent. */
|
||||
declare module 'luxon' {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const DateTime: any
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const IANAZone: any
|
||||
}
|
||||
156
src/run_daily.sh
Executable file
156
src/run_daily.sh
Executable file
@@ -0,0 +1,156 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export PATH="/usr/local/bin:/usr/bin:/bin"
|
||||
export PLAYWRIGHT_BROWSERS_PATH=0
|
||||
export TZ="${TZ:-UTC}"
|
||||
|
||||
cd /usr/src/microsoft-rewards-script
|
||||
|
||||
LOCKFILE=/tmp/run_daily.lock
|
||||
|
||||
# -------------------------------
|
||||
# Function: Check and fix lockfile integrity
|
||||
# -------------------------------
|
||||
self_heal_lockfile() {
|
||||
# If lockfile exists but is empty → remove it
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
local lock_content
|
||||
lock_content=$(<"$LOCKFILE" || echo "")
|
||||
|
||||
if [[ -z "$lock_content" ]]; then
|
||||
echo "[$(date)] [run_daily.sh] Found empty lockfile → removing."
|
||||
rm -f "$LOCKFILE"
|
||||
return
|
||||
fi
|
||||
|
||||
# If lockfile contains non-numeric PID → remove it
|
||||
if ! [[ "$lock_content" =~ ^[0-9]+$ ]]; then
|
||||
echo "[$(date)] [run_daily.sh] Found corrupted lockfile content ('$lock_content') → removing."
|
||||
rm -f "$LOCKFILE"
|
||||
return
|
||||
fi
|
||||
|
||||
# If lockfile contains PID but process is dead → remove it
|
||||
if ! kill -0 "$lock_content" 2>/dev/null; then
|
||||
echo "[$(date)] [run_daily.sh] Lockfile PID $lock_content is dead → removing stale lock."
|
||||
rm -f "$LOCKFILE"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# -------------------------------
|
||||
# Function: Acquire lock
|
||||
# -------------------------------
|
||||
acquire_lock() {
|
||||
local max_attempts=5
|
||||
local attempt=0
|
||||
local timeout_hours=${STUCK_PROCESS_TIMEOUT_HOURS:-8}
|
||||
local timeout_seconds=$((timeout_hours * 3600))
|
||||
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
# Try to create lock with current PID
|
||||
if (set -C; echo "$$" > "$LOCKFILE") 2>/dev/null; then
|
||||
echo "[$(date)] [run_daily.sh] Lock acquired successfully (PID: $$)"
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Lock exists, validate it
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
local existing_pid
|
||||
existing_pid=$(<"$LOCKFILE" || echo "")
|
||||
|
||||
echo "[$(date)] [run_daily.sh] Lock file exists with PID: '$existing_pid'"
|
||||
|
||||
# If lockfile content is invalid → delete and retry
|
||||
if [[ -z "$existing_pid" || ! "$existing_pid" =~ ^[0-9]+$ ]]; then
|
||||
echo "[$(date)] [run_daily.sh] Removing invalid lockfile → retrying..."
|
||||
rm -f "$LOCKFILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
# If process is dead → delete and retry
|
||||
if ! kill -0 "$existing_pid" 2>/dev/null; then
|
||||
echo "[$(date)] [run_daily.sh] Removing stale lock (dead PID: $existing_pid)"
|
||||
rm -f "$LOCKFILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check process runtime → kill if exceeded timeout
|
||||
local process_age
|
||||
if process_age=$(ps -o etimes= -p "$existing_pid" 2>/dev/null | tr -d ' '); then
|
||||
if [ "$process_age" -gt "$timeout_seconds" ]; then
|
||||
echo "[$(date)] [run_daily.sh] Killing stuck process $existing_pid (${process_age}s > ${timeout_hours}h)"
|
||||
kill -TERM "$existing_pid" 2>/dev/null || true
|
||||
sleep 5
|
||||
kill -KILL "$existing_pid" 2>/dev/null || true
|
||||
rm -f "$LOCKFILE"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[$(date)] [run_daily.sh] Lock held by PID $existing_pid, attempt $((attempt + 1))/$max_attempts"
|
||||
sleep 2
|
||||
((attempt++))
|
||||
done
|
||||
|
||||
echo "[$(date)] [run_daily.sh] Could not acquire lock after $max_attempts attempts; exiting."
|
||||
return 1
|
||||
}
|
||||
|
||||
# -------------------------------
|
||||
# Function: Release lock
|
||||
# -------------------------------
|
||||
release_lock() {
|
||||
if [ -f "$LOCKFILE" ]; then
|
||||
local lock_pid
|
||||
lock_pid=$(<"$LOCKFILE")
|
||||
if [ "$lock_pid" = "$$" ]; then
|
||||
rm -f "$LOCKFILE"
|
||||
echo "[$(date)] [run_daily.sh] Lock released (PID: $$)"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Always release lock on exit — but only if we acquired it
|
||||
trap 'release_lock' EXIT INT TERM
|
||||
|
||||
# -------------------------------
|
||||
# MAIN EXECUTION FLOW
|
||||
# -------------------------------
|
||||
echo "[$(date)] [run_daily.sh] Current process PID: $$"
|
||||
|
||||
# Self-heal any broken or empty locks before proceeding
|
||||
self_heal_lockfile
|
||||
|
||||
# Attempt to acquire the lock safely
|
||||
if ! acquire_lock; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Random sleep between MIN and MAX to spread execution
|
||||
MINWAIT=${MIN_SLEEP_MINUTES:-5}
|
||||
MAXWAIT=${MAX_SLEEP_MINUTES:-50}
|
||||
MINWAIT_SEC=$((MINWAIT*60))
|
||||
MAXWAIT_SEC=$((MAXWAIT*60))
|
||||
|
||||
if [ "${SKIP_RANDOM_SLEEP:-false}" != "true" ]; then
|
||||
SLEEPTIME=$(( MINWAIT_SEC + RANDOM % (MAXWAIT_SEC - MINWAIT_SEC) ))
|
||||
echo "[$(date)] [run_daily.sh] Sleeping for $((SLEEPTIME/60)) minutes ($SLEEPTIME seconds)"
|
||||
sleep "$SLEEPTIME"
|
||||
else
|
||||
echo "[$(date)] [run_daily.sh] Skipping random sleep"
|
||||
fi
|
||||
|
||||
# Start the actual script
|
||||
echo "[$(date)] [run_daily.sh] Starting script..."
|
||||
if npm start; then
|
||||
echo "[$(date)] [run_daily.sh] Script completed successfully."
|
||||
else
|
||||
echo "[$(date)] [run_daily.sh] ERROR: Script failed!" >&2
|
||||
fi
|
||||
|
||||
echo "[$(date)] [run_daily.sh] Script finished"
|
||||
# Lock is released automatically via trap
|
||||
25
src/util/AdaptiveThrottler.ts
Normal file
25
src/util/AdaptiveThrottler.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export class AdaptiveThrottler {
|
||||
private errorCount = 0
|
||||
private successCount = 0
|
||||
private window: Array<{ ok: boolean; at: number }> = []
|
||||
private readonly maxWindow = 50
|
||||
|
||||
record(ok: boolean) {
|
||||
this.window.push({ ok, at: Date.now() })
|
||||
if (ok) this.successCount++
|
||||
else this.errorCount++
|
||||
if (this.window.length > this.maxWindow) {
|
||||
const removed = this.window.shift()
|
||||
if (removed) removed.ok ? this.successCount-- : this.errorCount--
|
||||
}
|
||||
}
|
||||
|
||||
/** Return a multiplier to apply to waits (1 = normal). */
|
||||
getDelayMultiplier(): number {
|
||||
const total = Math.max(1, this.successCount + this.errorCount)
|
||||
const errRatio = this.errorCount / total
|
||||
// 0% errors -> 1x; 50% errors -> ~1.8x; 80% -> ~2.5x (cap)
|
||||
const mult = 1 + Math.min(1.5, errRatio * 2)
|
||||
return Number(mult.toFixed(2))
|
||||
}
|
||||
}
|
||||
96
src/util/Axios.ts
Normal file
96
src/util/Axios.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { HttpProxyAgent } from 'http-proxy-agent'
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent'
|
||||
import { AccountProxy } from '../interface/Account'
|
||||
|
||||
class AxiosClient {
|
||||
private instance: AxiosInstance
|
||||
private account: AccountProxy
|
||||
|
||||
constructor(account: AccountProxy) {
|
||||
this.account = account
|
||||
this.instance = axios.create()
|
||||
|
||||
// If a proxy configuration is provided, set up the agent
|
||||
if (this.account.url && this.account.proxyAxios) {
|
||||
const agent = this.getAgentForProxy(this.account)
|
||||
this.instance.defaults.httpAgent = agent
|
||||
this.instance.defaults.httpsAgent = agent
|
||||
}
|
||||
}
|
||||
|
||||
private getAgentForProxy(proxyConfig: AccountProxy): HttpProxyAgent<string> | HttpsProxyAgent<string> | SocksProxyAgent {
|
||||
const { url, port } = proxyConfig
|
||||
|
||||
switch (true) {
|
||||
case proxyConfig.url.startsWith('http://'):
|
||||
return new HttpProxyAgent(`${url}:${port}`)
|
||||
case proxyConfig.url.startsWith('https://'):
|
||||
return new HttpsProxyAgent(`${url}:${port}`)
|
||||
case proxyConfig.url.startsWith('socks://') || proxyConfig.url.startsWith('socks4://') || proxyConfig.url.startsWith('socks5://'):
|
||||
return new SocksProxyAgent(`${url}:${port}`)
|
||||
default:
|
||||
throw new Error(`Unsupported proxy protocol in "${url}". Supported: http://, https://, socks://, socks4://, socks5://`)
|
||||
}
|
||||
}
|
||||
|
||||
// Generic method to make any Axios request
|
||||
public async request(config: AxiosRequestConfig, bypassProxy = false): Promise<AxiosResponse> {
|
||||
if (bypassProxy) {
|
||||
const bypassInstance = axios.create()
|
||||
return bypassInstance.request(config)
|
||||
}
|
||||
|
||||
let lastError: unknown
|
||||
const maxAttempts = 2
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await this.instance.request(config)
|
||||
} catch (err: unknown) {
|
||||
lastError = err
|
||||
const axiosErr = err as AxiosError | undefined
|
||||
|
||||
// Detect HTTP proxy auth failures (status 407) and retry without proxy
|
||||
if (axiosErr && axiosErr.response && axiosErr.response.status === 407) {
|
||||
if (attempt < maxAttempts) {
|
||||
await this.sleep(1000 * attempt) // Exponential backoff
|
||||
}
|
||||
const bypassInstance = axios.create()
|
||||
return bypassInstance.request(config)
|
||||
}
|
||||
|
||||
// If proxied request fails with common proxy/network errors, retry with backoff
|
||||
const e = err as { code?: string; cause?: { code?: string }; message?: string } | undefined
|
||||
const code = e?.code || e?.cause?.code
|
||||
const isNetErr = code === 'ECONNREFUSED' || code === 'ETIMEDOUT' || code === 'ECONNRESET' || code === 'ENOTFOUND'
|
||||
const msg = String(e?.message || '')
|
||||
const looksLikeProxyIssue = /proxy|tunnel|socks|agent/i.test(msg)
|
||||
|
||||
if (isNetErr || looksLikeProxyIssue) {
|
||||
if (attempt < maxAttempts) {
|
||||
// Exponential backoff: 1s, 2s, 4s, etc.
|
||||
const delayMs = 1000 * Math.pow(2, attempt - 1)
|
||||
await this.sleep(delayMs)
|
||||
continue
|
||||
}
|
||||
// Last attempt: try without proxy
|
||||
const bypassInstance = axios.create()
|
||||
return bypassInstance.request(config)
|
||||
}
|
||||
|
||||
// Non-retryable error
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
}
|
||||
|
||||
export default AxiosClient
|
||||
16
src/util/BanDetector.ts
Normal file
16
src/util/BanDetector.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type BanStatus = { status: boolean; reason: string }
|
||||
|
||||
const BAN_PATTERNS: Array<{ re: RegExp; reason: string }> = [
|
||||
{ re: /suspend|suspended|suspension/i, reason: 'account suspended' },
|
||||
{ re: /locked|lockout|serviceabuse|abuse/i, reason: 'locked or service abuse detected' },
|
||||
{ re: /unusual.*activity|unusual activity/i, reason: 'unusual activity prompts' },
|
||||
{ re: /verify.*identity|identity.*verification/i, reason: 'identity verification required' }
|
||||
]
|
||||
|
||||
export function detectBanReason(input: unknown): BanStatus {
|
||||
const s = input instanceof Error ? (input.message || '') : String(input || '')
|
||||
for (const p of BAN_PATTERNS) {
|
||||
if (p.re.test(s)) return { status: true, reason: p.reason }
|
||||
}
|
||||
return { status: false, reason: '' }
|
||||
}
|
||||
335
src/util/ConclusionWebhook.ts
Normal file
335
src/util/ConclusionWebhook.ts
Normal file
@@ -0,0 +1,335 @@
|
||||
import axios from 'axios'
|
||||
import { Config } from '../interface/Config'
|
||||
import { Ntfy } from './Ntfy'
|
||||
import { DISCORD } from '../constants'
|
||||
import { log } from './Logger'
|
||||
|
||||
interface DiscordField {
|
||||
name: string
|
||||
value: string
|
||||
inline?: boolean
|
||||
}
|
||||
|
||||
interface DiscordEmbed {
|
||||
title?: string
|
||||
description?: string
|
||||
color?: number
|
||||
fields?: DiscordField[]
|
||||
timestamp?: string
|
||||
footer?: {
|
||||
text: string
|
||||
icon_url?: string
|
||||
}
|
||||
thumbnail?: {
|
||||
url: string
|
||||
}
|
||||
author?: {
|
||||
name: string
|
||||
icon_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
interface AccountSummary {
|
||||
email: string
|
||||
totalCollected: number
|
||||
desktopCollected: number
|
||||
mobileCollected: number
|
||||
initialTotal: number
|
||||
endTotal: number
|
||||
durationMs: number
|
||||
errors: string[]
|
||||
banned?: { status: boolean; reason?: string }
|
||||
}
|
||||
|
||||
interface ConclusionData {
|
||||
version: string
|
||||
runId: string
|
||||
totalAccounts: number
|
||||
successes: number
|
||||
accountsWithErrors: number
|
||||
accountsBanned: number
|
||||
totalCollected: number
|
||||
totalInitial: number
|
||||
totalEnd: number
|
||||
avgPointsPerAccount: number
|
||||
totalDuration: number
|
||||
avgDuration: number
|
||||
summaries: AccountSummary[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a clean, structured Discord webhook notification
|
||||
*/
|
||||
export async function ConclusionWebhook(
|
||||
config: Config,
|
||||
title: string,
|
||||
description: string,
|
||||
fields?: DiscordField[],
|
||||
color?: number
|
||||
) {
|
||||
const hasConclusion = config.conclusionWebhook?.enabled && config.conclusionWebhook.url
|
||||
const hasWebhook = config.webhook?.enabled && config.webhook.url
|
||||
|
||||
if (!hasConclusion && !hasWebhook) return
|
||||
|
||||
const embed: DiscordEmbed = {
|
||||
title,
|
||||
description,
|
||||
color: color || 0x0078D4,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
if (fields && fields.length > 0) {
|
||||
embed.fields = fields
|
||||
}
|
||||
|
||||
const postWebhook = async (url: string, label: string) => {
|
||||
const maxAttempts = 3
|
||||
let lastError: unknown = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await axios.post(url,
|
||||
{
|
||||
embeds: [embed]
|
||||
},
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 15000
|
||||
})
|
||||
log('main', 'WEBHOOK', `${label} notification sent successfully (attempt ${attempt})`)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < maxAttempts) {
|
||||
// Exponential backoff: 1s, 2s, 4s
|
||||
const delayMs = 1000 * Math.pow(2, attempt - 1)
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
log('main', 'WEBHOOK', `${label} failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, 'error')
|
||||
}
|
||||
|
||||
const urls = new Set<string>()
|
||||
if (hasConclusion) urls.add(config.conclusionWebhook!.url)
|
||||
if (hasWebhook) urls.add(config.webhook!.url)
|
||||
|
||||
await Promise.all(
|
||||
Array.from(urls).map((url, index) => postWebhook(url, `webhook-${index + 1}`))
|
||||
)
|
||||
|
||||
// Optional NTFY notification
|
||||
if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) {
|
||||
const message = `${title}\n${description}${fields ? '\n\n' + fields.map(f => `${f.name}: ${f.value}`).join('\n') : ''}`
|
||||
const ntfyType = color === 0xFF0000 ? 'error' : color === 0xFFAA00 ? 'warn' : 'log'
|
||||
|
||||
try {
|
||||
await Ntfy(message, ntfyType)
|
||||
log('main', 'NTFY', 'Notification sent successfully')
|
||||
} catch (error) {
|
||||
log('main', 'NTFY', `Failed to send notification: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enhanced conclusion webhook with beautiful formatting and clear statistics
|
||||
*/
|
||||
export async function ConclusionWebhookEnhanced(config: Config, data: ConclusionData) {
|
||||
const hasConclusion = config.conclusionWebhook?.enabled && config.conclusionWebhook.url
|
||||
const hasWebhook = config.webhook?.enabled && config.webhook.url
|
||||
|
||||
if (!hasConclusion && !hasWebhook) return
|
||||
|
||||
// Helper to format duration
|
||||
const formatDuration = (ms: number): string => {
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const hours = Math.floor(totalSeconds / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
|
||||
if (hours > 0) return `${hours}h ${minutes}m ${seconds}s`
|
||||
if (minutes > 0) return `${minutes}m ${seconds}s`
|
||||
return `${seconds}s`
|
||||
}
|
||||
|
||||
// Helper to create progress bar (future use)
|
||||
// const createProgressBar = (current: number, max: number, length: number = 10): string => {
|
||||
// const percentage = Math.min(100, Math.max(0, (current / max) * 100))
|
||||
// const filled = Math.round((percentage / 100) * length)
|
||||
// const empty = length - filled
|
||||
// return `${'█'.repeat(filled)}${'░'.repeat(empty)} ${percentage.toFixed(0)}%`
|
||||
// }
|
||||
|
||||
// Determine overall status and color
|
||||
let statusEmoji = '✅'
|
||||
let statusText = 'Success'
|
||||
let embedColor: number = DISCORD.COLOR_GREEN
|
||||
|
||||
if (data.accountsBanned > 0) {
|
||||
statusEmoji = '🚫'
|
||||
statusText = 'Banned Accounts Detected'
|
||||
embedColor = DISCORD.COLOR_RED
|
||||
} else if (data.accountsWithErrors > 0) {
|
||||
statusEmoji = '⚠️'
|
||||
statusText = 'Completed with Warnings'
|
||||
embedColor = DISCORD.COLOR_ORANGE
|
||||
}
|
||||
|
||||
// Build main summary description
|
||||
const mainDescription = [
|
||||
`**Status:** ${statusEmoji} ${statusText}`,
|
||||
`**Version:** v${data.version} • **Run ID:** \`${data.runId}\``,
|
||||
'',
|
||||
'━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'
|
||||
].join('\n')
|
||||
|
||||
// Build global statistics field
|
||||
const globalStats = [
|
||||
'**💎 Total Points Earned**',
|
||||
`\`${data.totalInitial.toLocaleString()}\` → \`${data.totalEnd.toLocaleString()}\` **(+${data.totalCollected.toLocaleString()})**`,
|
||||
'',
|
||||
'**📊 Accounts Processed**',
|
||||
`✅ Success: **${data.successes}** | ⚠️ Errors: **${data.accountsWithErrors}** | 🚫 Banned: **${data.accountsBanned}**`,
|
||||
`Total: **${data.totalAccounts}** ${data.totalAccounts === 1 ? 'account' : 'accounts'}`,
|
||||
'',
|
||||
'**⚡ Performance**',
|
||||
`Average: **${data.avgPointsPerAccount}pts/account** in **${formatDuration(data.avgDuration)}**`,
|
||||
`Total Runtime: **${formatDuration(data.totalDuration)}**`
|
||||
].join('\n')
|
||||
|
||||
// Build per-account breakdown (split if too many accounts)
|
||||
const accountFields: DiscordField[] = []
|
||||
const maxAccountsPerField = 5
|
||||
const accountChunks: AccountSummary[][] = []
|
||||
|
||||
for (let i = 0; i < data.summaries.length; i += maxAccountsPerField) {
|
||||
accountChunks.push(data.summaries.slice(i, i + maxAccountsPerField))
|
||||
}
|
||||
|
||||
accountChunks.forEach((chunk, chunkIndex) => {
|
||||
const accountLines: string[] = []
|
||||
|
||||
chunk.forEach((acc) => {
|
||||
const statusIcon = acc.banned?.status ? '🚫' : (acc.errors.length > 0 ? '⚠️' : '✅')
|
||||
const emailShort = acc.email.length > 25 ? acc.email.substring(0, 22) + '...' : acc.email
|
||||
|
||||
accountLines.push(`${statusIcon} **${emailShort}**`)
|
||||
accountLines.push(`└ Points: **+${acc.totalCollected}** (🖥️ ${acc.desktopCollected} • 📱 ${acc.mobileCollected})`)
|
||||
accountLines.push(`└ Duration: ${formatDuration(acc.durationMs)}`)
|
||||
|
||||
if (acc.banned?.status) {
|
||||
accountLines.push(`└ 🚫 **Banned:** ${acc.banned.reason || 'Account suspended'}`)
|
||||
} else if (acc.errors.length > 0) {
|
||||
const errorPreview = acc.errors.slice(0, 1).join(', ')
|
||||
accountLines.push(`└ ⚠️ **Error:** ${errorPreview.length > 50 ? errorPreview.substring(0, 47) + '...' : errorPreview}`)
|
||||
}
|
||||
|
||||
accountLines.push('') // Empty line between accounts
|
||||
})
|
||||
|
||||
const fieldName = accountChunks.length > 1
|
||||
? `📈 Account Details (${chunkIndex + 1}/${accountChunks.length})`
|
||||
: '📈 Account Details'
|
||||
|
||||
accountFields.push({
|
||||
name: fieldName,
|
||||
value: accountLines.join('\n').trim(),
|
||||
inline: false
|
||||
})
|
||||
})
|
||||
|
||||
// Create embeds
|
||||
const embeds: DiscordEmbed[] = []
|
||||
|
||||
// Main embed with summary
|
||||
embeds.push({
|
||||
title: '🎯 Microsoft Rewards — Daily Summary',
|
||||
description: mainDescription,
|
||||
color: embedColor,
|
||||
fields: [
|
||||
{
|
||||
name: '📊 Global Statistics',
|
||||
value: globalStats,
|
||||
inline: false
|
||||
}
|
||||
],
|
||||
footer: {
|
||||
text: `Microsoft Rewards Bot v${data.version} • Completed at`
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
|
||||
// Add account details in separate embed(s) if needed
|
||||
if (accountFields.length > 0) {
|
||||
// If we have multiple fields, split into multiple embeds
|
||||
accountFields.forEach((field, index) => {
|
||||
if (index === 0 && embeds[0] && embeds[0].fields) {
|
||||
// Add first field to main embed
|
||||
embeds[0].fields.push(field)
|
||||
} else {
|
||||
// Create additional embeds for remaining fields
|
||||
embeds.push({
|
||||
color: embedColor,
|
||||
fields: [field],
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const postWebhook = async (url: string, label: string) => {
|
||||
const maxAttempts = 3
|
||||
let lastError: unknown = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await axios.post(url, {
|
||||
embeds: embeds
|
||||
}, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 15000
|
||||
})
|
||||
log('main', 'WEBHOOK', `${label} conclusion sent successfully (${data.totalAccounts} accounts, +${data.totalCollected}pts)`)
|
||||
return
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt < maxAttempts) {
|
||||
const delayMs = 1000 * Math.pow(2, attempt - 1)
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
log('main', 'WEBHOOK', `${label} failed after ${maxAttempts} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, 'error')
|
||||
}
|
||||
|
||||
const urls = new Set<string>()
|
||||
if (hasConclusion) urls.add(config.conclusionWebhook!.url)
|
||||
if (hasWebhook) urls.add(config.webhook!.url)
|
||||
|
||||
await Promise.all(
|
||||
Array.from(urls).map((url, index) => postWebhook(url, `conclusion-webhook-${index + 1}`))
|
||||
)
|
||||
|
||||
// Optional NTFY notification (simplified summary)
|
||||
if (config.ntfy?.enabled && config.ntfy.url && config.ntfy.topic) {
|
||||
const message = [
|
||||
'🎯 Microsoft Rewards Summary',
|
||||
`Status: ${statusText}`,
|
||||
`Points: ${data.totalInitial} → ${data.totalEnd} (+${data.totalCollected})`,
|
||||
`Accounts: ${data.successes}/${data.totalAccounts} successful`,
|
||||
`Duration: ${formatDuration(data.totalDuration)}`
|
||||
].join('\n')
|
||||
|
||||
const ntfyType = embedColor === DISCORD.COLOR_RED ? 'error' : embedColor === DISCORD.COLOR_ORANGE ? 'warn' : 'log'
|
||||
|
||||
try {
|
||||
await Ntfy(message, ntfyType)
|
||||
log('main', 'NTFY', 'Conclusion notification sent successfully')
|
||||
} catch (error) {
|
||||
log('main', 'NTFY', `Failed to send conclusion notification: ${error instanceof Error ? error.message : String(error)}`, 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
481
src/util/ConfigValidator.ts
Normal file
481
src/util/ConfigValidator.ts
Normal file
@@ -0,0 +1,481 @@
|
||||
import fs from 'fs'
|
||||
import { Config } from '../interface/Config'
|
||||
import { Account } from '../interface/Account'
|
||||
|
||||
export interface ValidationIssue {
|
||||
severity: 'error' | 'warning' | 'info'
|
||||
field: string
|
||||
message: string
|
||||
suggestion?: string
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
issues: ValidationIssue[]
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigValidator performs intelligent validation of config.json and accounts.json
|
||||
* before execution to catch common mistakes, conflicts, and security issues.
|
||||
*/
|
||||
export class ConfigValidator {
|
||||
/**
|
||||
* Validate the main config file
|
||||
*/
|
||||
static validateConfig(config: Config): ValidationResult {
|
||||
const issues: ValidationIssue[] = []
|
||||
|
||||
// Check baseURL
|
||||
if (!config.baseURL || !config.baseURL.startsWith('https://')) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'baseURL',
|
||||
message: 'baseURL must be a valid HTTPS URL',
|
||||
suggestion: 'Use https://rewards.bing.com'
|
||||
})
|
||||
}
|
||||
|
||||
// Check sessionPath
|
||||
if (!config.sessionPath || config.sessionPath.trim() === '') {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'sessionPath',
|
||||
message: 'sessionPath cannot be empty'
|
||||
})
|
||||
}
|
||||
|
||||
// Check clusters
|
||||
if (config.clusters < 1) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'clusters',
|
||||
message: 'clusters must be at least 1'
|
||||
})
|
||||
}
|
||||
if (config.clusters > 10) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'clusters',
|
||||
message: 'High cluster count may consume excessive resources',
|
||||
suggestion: 'Consider using 2-4 clusters for optimal performance'
|
||||
})
|
||||
}
|
||||
|
||||
// Check globalTimeout
|
||||
const timeout = this.parseTimeout(config.globalTimeout)
|
||||
if (timeout < 10000) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'globalTimeout',
|
||||
message: 'Very short timeout may cause frequent failures',
|
||||
suggestion: 'Use at least 15s for stability'
|
||||
})
|
||||
}
|
||||
if (timeout > 120000) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'globalTimeout',
|
||||
message: 'Very long timeout may slow down execution',
|
||||
suggestion: 'Use 30-60s for optimal balance'
|
||||
})
|
||||
}
|
||||
|
||||
// Check search settings
|
||||
if (config.searchSettings) {
|
||||
const searchDelay = config.searchSettings.searchDelay
|
||||
const minDelay = this.parseTimeout(searchDelay.min)
|
||||
const maxDelay = this.parseTimeout(searchDelay.max)
|
||||
|
||||
if (minDelay >= maxDelay) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'searchSettings.searchDelay',
|
||||
message: 'min delay must be less than max delay'
|
||||
})
|
||||
}
|
||||
|
||||
if (minDelay < 10000) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'searchSettings.searchDelay.min',
|
||||
message: 'Very short search delays increase ban risk',
|
||||
suggestion: 'Use at least 30s between searches'
|
||||
})
|
||||
}
|
||||
|
||||
if (config.searchSettings.retryMobileSearchAmount > 5) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'searchSettings.retryMobileSearchAmount',
|
||||
message: 'Too many retries may waste time',
|
||||
suggestion: 'Use 2-3 retries maximum'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check humanization
|
||||
if (config.humanization) {
|
||||
if (config.humanization.enabled === false && config.humanization.stopOnBan === true) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'humanization',
|
||||
message: 'stopOnBan is enabled but humanization is disabled',
|
||||
suggestion: 'Enable humanization for better ban protection'
|
||||
})
|
||||
}
|
||||
|
||||
const actionDelay = config.humanization.actionDelay
|
||||
if (actionDelay) {
|
||||
const minAction = this.parseTimeout(actionDelay.min)
|
||||
const maxAction = this.parseTimeout(actionDelay.max)
|
||||
if (minAction >= maxAction) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'humanization.actionDelay',
|
||||
message: 'min action delay must be less than max'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (config.humanization.allowedWindows && config.humanization.allowedWindows.length > 0) {
|
||||
for (const window of config.humanization.allowedWindows) {
|
||||
if (!/^\d{2}:\d{2}-\d{2}:\d{2}$/.test(window)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'humanization.allowedWindows',
|
||||
message: `Invalid time window format: ${window}`,
|
||||
suggestion: 'Use format HH:mm-HH:mm (e.g., 09:00-17:00)'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check proxy config
|
||||
if (config.proxy) {
|
||||
if (config.proxy.proxyGoogleTrends === false && config.proxy.proxyBingTerms === false) {
|
||||
issues.push({
|
||||
severity: 'info',
|
||||
field: 'proxy',
|
||||
message: 'All proxy options disabled - outbound requests will use direct connection'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check webhooks
|
||||
if (config.webhook?.enabled && (!config.webhook.url || config.webhook.url.trim() === '')) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'webhook.url',
|
||||
message: 'Webhook enabled but URL is empty'
|
||||
})
|
||||
}
|
||||
|
||||
if (config.conclusionWebhook?.enabled && (!config.conclusionWebhook.url || config.conclusionWebhook.url.trim() === '')) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'conclusionWebhook.url',
|
||||
message: 'Conclusion webhook enabled but URL is empty'
|
||||
})
|
||||
}
|
||||
|
||||
// Check ntfy
|
||||
if (config.ntfy?.enabled) {
|
||||
if (!config.ntfy.url || config.ntfy.url.trim() === '') {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'ntfy.url',
|
||||
message: 'NTFY enabled but URL is empty'
|
||||
})
|
||||
}
|
||||
if (!config.ntfy.topic || config.ntfy.topic.trim() === '') {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'ntfy.topic',
|
||||
message: 'NTFY enabled but topic is empty'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check workers
|
||||
if (config.workers) {
|
||||
const allDisabled = !config.workers.doDailySet &&
|
||||
!config.workers.doMorePromotions &&
|
||||
!config.workers.doPunchCards &&
|
||||
!config.workers.doDesktopSearch &&
|
||||
!config.workers.doMobileSearch &&
|
||||
!config.workers.doDailyCheckIn &&
|
||||
!config.workers.doReadToEarn
|
||||
|
||||
if (allDisabled) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'workers',
|
||||
message: 'All workers are disabled - bot will not perform any tasks',
|
||||
suggestion: 'Enable at least one worker type'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const valid = !issues.some(i => i.severity === 'error')
|
||||
return { valid, issues }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate accounts.json
|
||||
*/
|
||||
static validateAccounts(accounts: Account[]): ValidationResult {
|
||||
const issues: ValidationIssue[] = []
|
||||
|
||||
if (accounts.length === 0) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: 'accounts',
|
||||
message: 'No accounts found in accounts.json'
|
||||
})
|
||||
return { valid: false, issues }
|
||||
}
|
||||
|
||||
const seenEmails = new Set<string>()
|
||||
const seenProxies = new Map<string, string[]>() // proxy -> [emails]
|
||||
|
||||
for (let i = 0; i < accounts.length; i++) {
|
||||
const acc = accounts[i]
|
||||
const prefix = `accounts[${i}]`
|
||||
|
||||
if (!acc) continue
|
||||
|
||||
// Check email
|
||||
if (!acc.email || acc.email.trim() === '') {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: `${prefix}.email`,
|
||||
message: 'Account email is empty'
|
||||
})
|
||||
} else {
|
||||
if (seenEmails.has(acc.email)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: `${prefix}.email`,
|
||||
message: `Duplicate email: ${acc.email}`
|
||||
})
|
||||
}
|
||||
seenEmails.add(acc.email)
|
||||
|
||||
if (!/@/.test(acc.email)) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: `${prefix}.email`,
|
||||
message: 'Invalid email format'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check password
|
||||
if (!acc.password || acc.password.trim() === '') {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: `${prefix}.password`,
|
||||
message: 'Account password is empty'
|
||||
})
|
||||
} else if (acc.password.length < 8) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: `${prefix}.password`,
|
||||
message: 'Very short password - verify it\'s correct'
|
||||
})
|
||||
}
|
||||
|
||||
// Check proxy
|
||||
if (acc.proxy) {
|
||||
const proxyUrl = acc.proxy.url
|
||||
if (proxyUrl && proxyUrl.trim() !== '') {
|
||||
if (!acc.proxy.port) {
|
||||
issues.push({
|
||||
severity: 'error',
|
||||
field: `${prefix}.proxy.port`,
|
||||
message: 'Proxy URL specified but port is missing'
|
||||
})
|
||||
}
|
||||
|
||||
// Track proxy reuse
|
||||
const proxyKey = `${proxyUrl}:${acc.proxy.port}`
|
||||
if (!seenProxies.has(proxyKey)) {
|
||||
seenProxies.set(proxyKey, [])
|
||||
}
|
||||
seenProxies.get(proxyKey)?.push(acc.email)
|
||||
}
|
||||
}
|
||||
|
||||
// Check TOTP
|
||||
if (acc.totp && acc.totp.trim() !== '') {
|
||||
if (acc.totp.length < 16) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: `${prefix}.totp`,
|
||||
message: 'TOTP secret seems too short - verify it\'s correct'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Warn about excessive proxy reuse
|
||||
for (const [proxyKey, emails] of seenProxies) {
|
||||
if (emails.length > 3) {
|
||||
issues.push({
|
||||
severity: 'warning',
|
||||
field: 'accounts.proxy',
|
||||
message: `Proxy ${proxyKey} used by ${emails.length} accounts - may trigger rate limits`,
|
||||
suggestion: 'Use different proxies per account for better safety'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const valid = !issues.some(i => i.severity === 'error')
|
||||
return { valid, issues }
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate both config and accounts together (cross-checks)
|
||||
*/
|
||||
static validateAll(config: Config, accounts: Account[]): ValidationResult {
|
||||
const configResult = this.validateConfig(config)
|
||||
const accountsResult = this.validateAccounts(accounts)
|
||||
|
||||
const issues = [...configResult.issues, ...accountsResult.issues]
|
||||
|
||||
// Cross-validation: clusters vs accounts
|
||||
if (accounts.length > 0 && config.clusters > accounts.length) {
|
||||
issues.push({
|
||||
severity: 'info',
|
||||
field: 'clusters',
|
||||
message: `${config.clusters} clusters configured but only ${accounts.length} account(s)`,
|
||||
suggestion: 'Reduce clusters to match account count for efficiency'
|
||||
})
|
||||
}
|
||||
|
||||
// Cross-validation: parallel mode with single account
|
||||
if (config.parallel && accounts.length === 1) {
|
||||
issues.push({
|
||||
severity: 'info',
|
||||
field: 'parallel',
|
||||
message: 'Parallel mode enabled with single account has no effect',
|
||||
suggestion: 'Disable parallel mode or add more accounts'
|
||||
})
|
||||
}
|
||||
|
||||
const valid = !issues.some(i => i.severity === 'error')
|
||||
return { valid, issues }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and validate from file paths
|
||||
*/
|
||||
static validateFromFiles(configPath: string, accountsPath: string): ValidationResult {
|
||||
try {
|
||||
if (!fs.existsSync(configPath)) {
|
||||
return {
|
||||
valid: false,
|
||||
issues: [{
|
||||
severity: 'error',
|
||||
field: 'config',
|
||||
message: `Config file not found: ${configPath}`
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(accountsPath)) {
|
||||
return {
|
||||
valid: false,
|
||||
issues: [{
|
||||
severity: 'error',
|
||||
field: 'accounts',
|
||||
message: `Accounts file not found: ${accountsPath}`
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
const configRaw = fs.readFileSync(configPath, 'utf-8')
|
||||
const accountsRaw = fs.readFileSync(accountsPath, 'utf-8')
|
||||
|
||||
const configJson = configRaw.replace(/\/\*[\s\S]*?\*\/|\/\/.*/g, '')
|
||||
const config: Config = JSON.parse(configJson)
|
||||
const accounts: Account[] = JSON.parse(accountsRaw)
|
||||
|
||||
return this.validateAll(config, accounts)
|
||||
} catch (error) {
|
||||
return {
|
||||
valid: false,
|
||||
issues: [{
|
||||
severity: 'error',
|
||||
field: 'parse',
|
||||
message: `Failed to parse files: ${error instanceof Error ? error.message : String(error)}`
|
||||
}]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print validation results to console with color
|
||||
* Note: This method intentionally uses console.log for CLI output formatting
|
||||
*/
|
||||
static printResults(result: ValidationResult): void {
|
||||
if (result.valid) {
|
||||
console.log('✅ Configuration validation passed\n')
|
||||
} else {
|
||||
console.log('❌ Configuration validation failed\n')
|
||||
}
|
||||
|
||||
if (result.issues.length === 0) {
|
||||
console.log('No issues found.')
|
||||
return
|
||||
}
|
||||
|
||||
const errors = result.issues.filter(i => i.severity === 'error')
|
||||
const warnings = result.issues.filter(i => i.severity === 'warning')
|
||||
const infos = result.issues.filter(i => i.severity === 'info')
|
||||
|
||||
if (errors.length > 0) {
|
||||
console.log(`\n🚫 ERRORS (${errors.length}):`)
|
||||
for (const issue of errors) {
|
||||
console.log(` ${issue.field}: ${issue.message}`)
|
||||
if (issue.suggestion) {
|
||||
console.log(` → ${issue.suggestion}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (warnings.length > 0) {
|
||||
console.log(`\n⚠️ WARNINGS (${warnings.length}):`)
|
||||
for (const issue of warnings) {
|
||||
console.log(` ${issue.field}: ${issue.message}`)
|
||||
if (issue.suggestion) {
|
||||
console.log(` → ${issue.suggestion}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (infos.length > 0) {
|
||||
console.log(`\nℹ️ INFO (${infos.length}):`)
|
||||
for (const issue of infos) {
|
||||
console.log(` ${issue.field}: ${issue.message}`)
|
||||
if (issue.suggestion) {
|
||||
console.log(` → ${issue.suggestion}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log()
|
||||
}
|
||||
|
||||
private static parseTimeout(value: number | string): number {
|
||||
if (typeof value === 'number') return value
|
||||
const str = String(value).toLowerCase()
|
||||
if (str.endsWith('ms')) return parseInt(str, 10)
|
||||
if (str.endsWith('s')) return parseInt(str, 10) * 1000
|
||||
if (str.endsWith('min')) return parseInt(str, 10) * 60000
|
||||
return parseInt(str, 10) || 30000
|
||||
}
|
||||
}
|
||||
54
src/util/Humanizer.ts
Normal file
54
src/util/Humanizer.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
import Util from './Utils'
|
||||
import type { ConfigHumanization } from '../interface/Config'
|
||||
|
||||
export class Humanizer {
|
||||
private util: Util
|
||||
private cfg: ConfigHumanization | undefined
|
||||
|
||||
constructor(util: Util, cfg?: ConfigHumanization) {
|
||||
this.util = util
|
||||
this.cfg = cfg
|
||||
}
|
||||
|
||||
async microGestures(page: Page): Promise<void> {
|
||||
if (this.cfg && this.cfg.enabled === false) return
|
||||
const moveProb = this.cfg?.gestureMoveProb ?? 0.4
|
||||
const scrollProb = this.cfg?.gestureScrollProb ?? 0.2
|
||||
try {
|
||||
if (Math.random() < moveProb) {
|
||||
const x = Math.floor(Math.random() * 40) + 5
|
||||
const y = Math.floor(Math.random() * 30) + 5
|
||||
await page.mouse.move(x, y, { steps: 2 }).catch(() => {})
|
||||
}
|
||||
if (Math.random() < scrollProb) {
|
||||
const dy = (Math.random() < 0.5 ? 1 : -1) * (Math.floor(Math.random() * 150) + 50)
|
||||
await page.mouse.wheel(0, dy).catch(() => {})
|
||||
}
|
||||
} catch {/* noop */}
|
||||
}
|
||||
|
||||
async actionPause(): Promise<void> {
|
||||
if (this.cfg && this.cfg.enabled === false) return
|
||||
const defMin = 150
|
||||
const defMax = 450
|
||||
let min = defMin
|
||||
let max = defMax
|
||||
if (this.cfg?.actionDelay) {
|
||||
const parse = (v: number | string) => {
|
||||
if (typeof v === 'number') return v
|
||||
try {
|
||||
const n = this.util.stringToMs(String(v))
|
||||
return Math.max(0, Math.min(n, 10_000))
|
||||
} catch { return defMin }
|
||||
}
|
||||
min = parse(this.cfg.actionDelay.min)
|
||||
max = parse(this.cfg.actionDelay.max)
|
||||
if (min > max) [min, max] = [max, min]
|
||||
max = Math.min(max, 5_000)
|
||||
}
|
||||
await this.util.wait(this.util.randomNumber(min, max))
|
||||
}
|
||||
}
|
||||
|
||||
export default Humanizer
|
||||
58
src/util/JobState.ts
Normal file
58
src/util/JobState.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { Config } from '../interface/Config'
|
||||
|
||||
type DayState = {
|
||||
doneOfferIds: string[]
|
||||
}
|
||||
|
||||
type FileState = {
|
||||
days: Record<string, DayState>
|
||||
}
|
||||
|
||||
export class JobState {
|
||||
private baseDir: string
|
||||
|
||||
constructor(cfg: Config) {
|
||||
const dir = cfg.jobState?.dir || path.join(process.cwd(), cfg.sessionPath, 'job-state')
|
||||
this.baseDir = dir
|
||||
if (!fs.existsSync(this.baseDir)) fs.mkdirSync(this.baseDir, { recursive: true })
|
||||
}
|
||||
|
||||
private fileFor(email: string): string {
|
||||
const safe = email.replace(/[^a-z0-9._-]/gi, '_')
|
||||
return path.join(this.baseDir, `${safe}.json`)
|
||||
}
|
||||
|
||||
private load(email: string): FileState {
|
||||
const file = this.fileFor(email)
|
||||
if (!fs.existsSync(file)) return { days: {} }
|
||||
try {
|
||||
const raw = fs.readFileSync(file, 'utf-8')
|
||||
const parsed = JSON.parse(raw)
|
||||
return parsed && typeof parsed === 'object' && parsed.days ? parsed as FileState : { days: {} }
|
||||
} catch { return { days: {} } }
|
||||
}
|
||||
|
||||
private save(email: string, state: FileState): void {
|
||||
const file = this.fileFor(email)
|
||||
fs.writeFileSync(file, JSON.stringify(state, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
isDone(email: string, day: string, offerId: string): boolean {
|
||||
const st = this.load(email)
|
||||
const d = st.days[day]
|
||||
if (!d) return false
|
||||
return d.doneOfferIds.includes(offerId)
|
||||
}
|
||||
|
||||
markDone(email: string, day: string, offerId: string): void {
|
||||
const st = this.load(email)
|
||||
if (!st.days[day]) st.days[day] = { doneOfferIds: [] }
|
||||
const d = st.days[day]
|
||||
if (!d.doneOfferIds.includes(offerId)) d.doneOfferIds.push(offerId)
|
||||
this.save(email, st)
|
||||
}
|
||||
}
|
||||
|
||||
export default JobState
|
||||
294
src/util/Load.ts
294
src/util/Load.ts
@@ -1,35 +1,307 @@
|
||||
import { BrowserContext, Cookie } from 'rebrowser-playwright'
|
||||
import { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { Account } from '../interface/Account'
|
||||
import { Config } from '../interface/Config'
|
||||
|
||||
import { Account } from '../interface/Account'
|
||||
import { Config, ConfigSaveFingerprint } from '../interface/Config'
|
||||
|
||||
let configCache: Config
|
||||
let configSourcePath = ''
|
||||
|
||||
|
||||
// Normalize both legacy (flat) and new (nested) config schemas into the flat Config interface
|
||||
function normalizeConfig(raw: unknown): Config {
|
||||
// Using any here is necessary to support both legacy flat config and new nested config structures
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const n = (raw || {}) as any
|
||||
|
||||
// Browser / execution
|
||||
const headless = n.browser?.headless ?? n.headless ?? false
|
||||
const globalTimeout = n.browser?.globalTimeout ?? n.globalTimeout ?? '30s'
|
||||
const parallel = n.execution?.parallel ?? n.parallel ?? false
|
||||
const runOnZeroPoints = n.execution?.runOnZeroPoints ?? n.runOnZeroPoints ?? false
|
||||
const clusters = n.execution?.clusters ?? n.clusters ?? 1
|
||||
|
||||
// Search
|
||||
const useLocalQueries = n.search?.useLocalQueries ?? n.searchOnBingLocalQueries ?? false
|
||||
const searchSettingsSrc = n.search?.settings ?? n.searchSettings ?? {}
|
||||
const delaySrc = searchSettingsSrc.delay ?? searchSettingsSrc.searchDelay ?? { min: '3min', max: '5min' }
|
||||
const searchSettings = {
|
||||
useGeoLocaleQueries: !!(searchSettingsSrc.useGeoLocaleQueries ?? false),
|
||||
scrollRandomResults: !!(searchSettingsSrc.scrollRandomResults ?? false),
|
||||
clickRandomResults: !!(searchSettingsSrc.clickRandomResults ?? false),
|
||||
retryMobileSearchAmount: Number(searchSettingsSrc.retryMobileSearchAmount ?? 2),
|
||||
searchDelay: {
|
||||
min: delaySrc.min ?? '3min',
|
||||
max: delaySrc.max ?? '5min'
|
||||
},
|
||||
localFallbackCount: Number(searchSettingsSrc.localFallbackCount ?? 25),
|
||||
extraFallbackRetries: Number(searchSettingsSrc.extraFallbackRetries ?? 1)
|
||||
}
|
||||
|
||||
// Workers
|
||||
const workers = n.workers ?? {
|
||||
doDailySet: true,
|
||||
doMorePromotions: true,
|
||||
doPunchCards: true,
|
||||
doDesktopSearch: true,
|
||||
doMobileSearch: true,
|
||||
doDailyCheckIn: true,
|
||||
doReadToEarn: true,
|
||||
bundleDailySetWithSearch: false
|
||||
}
|
||||
// Ensure missing flag gets a default
|
||||
if (typeof workers.bundleDailySetWithSearch !== 'boolean') workers.bundleDailySetWithSearch = false
|
||||
|
||||
// Logging
|
||||
const logging = n.logging ?? {}
|
||||
const logExcludeFunc = Array.isArray(logging.excludeFunc) ? logging.excludeFunc : (n.logExcludeFunc ?? [])
|
||||
const webhookLogExcludeFunc = Array.isArray(logging.webhookExcludeFunc) ? logging.webhookExcludeFunc : (n.webhookLogExcludeFunc ?? [])
|
||||
|
||||
// Notifications
|
||||
const notifications = n.notifications ?? {}
|
||||
const webhook = notifications.webhook ?? n.webhook ?? { enabled: false, url: '' }
|
||||
const conclusionWebhook = notifications.conclusionWebhook ?? n.conclusionWebhook ?? { enabled: false, url: '' }
|
||||
const ntfy = notifications.ntfy ?? n.ntfy ?? { enabled: false, url: '', topic: '', authToken: '' }
|
||||
|
||||
|
||||
// Fingerprinting
|
||||
const saveFingerprint = (n.fingerprinting?.saveFingerprint ?? n.saveFingerprint) ?? { mobile: false, desktop: false }
|
||||
|
||||
// Humanization defaults (single on/off)
|
||||
if (!n.humanization) n.humanization = {}
|
||||
if (typeof n.humanization.enabled !== 'boolean') n.humanization.enabled = true
|
||||
if (typeof n.humanization.stopOnBan !== 'boolean') n.humanization.stopOnBan = false
|
||||
if (typeof n.humanization.immediateBanAlert !== 'boolean') n.humanization.immediateBanAlert = true
|
||||
if (typeof n.humanization.randomOffDaysPerWeek !== 'number') {
|
||||
n.humanization.randomOffDaysPerWeek = 1
|
||||
}
|
||||
// Strong default gestures when enabled (explicit values still win)
|
||||
if (typeof n.humanization.gestureMoveProb !== 'number') {
|
||||
n.humanization.gestureMoveProb = n.humanization.enabled === false ? 0 : 0.5
|
||||
}
|
||||
if (typeof n.humanization.gestureScrollProb !== 'number') {
|
||||
n.humanization.gestureScrollProb = n.humanization.enabled === false ? 0 : 0.25
|
||||
}
|
||||
|
||||
// Vacation mode (monthly contiguous off-days)
|
||||
if (!n.vacation) n.vacation = {}
|
||||
if (typeof n.vacation.enabled !== 'boolean') n.vacation.enabled = false
|
||||
const vMin = Number(n.vacation.minDays)
|
||||
const vMax = Number(n.vacation.maxDays)
|
||||
n.vacation.minDays = isFinite(vMin) && vMin > 0 ? Math.floor(vMin) : 3
|
||||
n.vacation.maxDays = isFinite(vMax) && vMax > 0 ? Math.floor(vMax) : 5
|
||||
if (n.vacation.maxDays < n.vacation.minDays) {
|
||||
const t = n.vacation.minDays; n.vacation.minDays = n.vacation.maxDays; n.vacation.maxDays = t
|
||||
}
|
||||
|
||||
const cfg: Config = {
|
||||
baseURL: n.baseURL ?? 'https://rewards.bing.com',
|
||||
sessionPath: n.sessionPath ?? 'sessions',
|
||||
headless,
|
||||
parallel,
|
||||
runOnZeroPoints,
|
||||
clusters,
|
||||
saveFingerprint,
|
||||
workers,
|
||||
searchOnBingLocalQueries: !!useLocalQueries,
|
||||
globalTimeout,
|
||||
searchSettings,
|
||||
humanization: n.humanization,
|
||||
retryPolicy: n.retryPolicy,
|
||||
jobState: n.jobState,
|
||||
logExcludeFunc,
|
||||
webhookLogExcludeFunc,
|
||||
logging, // retain full logging object for live webhook usage
|
||||
proxy: n.proxy ?? { proxyGoogleTrends: true, proxyBingTerms: true },
|
||||
webhook,
|
||||
conclusionWebhook,
|
||||
ntfy,
|
||||
vacation: n.vacation,
|
||||
crashRecovery: n.crashRecovery || {}
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
export function loadAccounts(): Account[] {
|
||||
try {
|
||||
// 1) CLI dev override
|
||||
let file = 'accounts.json'
|
||||
|
||||
// If dev mode, use dev account(s)
|
||||
if (process.argv.includes('-dev')) {
|
||||
file = 'accounts.dev.json'
|
||||
}
|
||||
|
||||
const accountDir = path.join(__dirname, '../', file)
|
||||
const accounts = fs.readFileSync(accountDir, 'utf-8')
|
||||
// 2) Docker-friendly env overrides
|
||||
const envJson = process.env.ACCOUNTS_JSON
|
||||
const envFile = process.env.ACCOUNTS_FILE
|
||||
|
||||
return JSON.parse(accounts)
|
||||
let json: string | undefined
|
||||
if (envJson && envJson.trim().startsWith('[')) {
|
||||
json = envJson
|
||||
} else if (envFile && envFile.trim()) {
|
||||
const full = path.isAbsolute(envFile) ? envFile : path.join(process.cwd(), envFile)
|
||||
if (!fs.existsSync(full)) {
|
||||
throw new Error(`ACCOUNTS_FILE not found: ${full}`)
|
||||
}
|
||||
json = fs.readFileSync(full, 'utf-8')
|
||||
} else {
|
||||
// Try multiple locations to support both root mounts and dist mounts
|
||||
// Support both .json and .json extensions
|
||||
const candidates = [
|
||||
path.join(__dirname, '../', file),
|
||||
path.join(__dirname, '../src', file),
|
||||
path.join(process.cwd(), file),
|
||||
path.join(process.cwd(), 'src', file),
|
||||
path.join(__dirname, file)
|
||||
]
|
||||
let chosen: string | null = null
|
||||
for (const p of candidates) {
|
||||
try { if (fs.existsSync(p)) { chosen = p; break } } catch { /* ignore */ }
|
||||
}
|
||||
if (!chosen) throw new Error(`accounts file not found in: ${candidates.join(' | ')}`)
|
||||
json = fs.readFileSync(chosen, 'utf-8')
|
||||
}
|
||||
|
||||
// Support comments in accounts file (same as config)
|
||||
const parsedUnknown = JSON.parse(json)
|
||||
// Accept either a root array or an object with an `accounts` array, ignore `_note`
|
||||
const parsed = Array.isArray(parsedUnknown) ? parsedUnknown : (parsedUnknown && typeof parsedUnknown === 'object' && Array.isArray((parsedUnknown as { accounts?: unknown }).accounts) ? (parsedUnknown as { accounts: unknown[] }).accounts : null)
|
||||
if (!Array.isArray(parsed)) throw new Error('accounts must be an array')
|
||||
// minimal shape validation
|
||||
for (const a of parsed) {
|
||||
if (!a || typeof a.email !== 'string' || typeof a.password !== 'string') {
|
||||
throw new Error('each account must have email and password strings')
|
||||
}
|
||||
}
|
||||
// Filter out disabled accounts (enabled: false)
|
||||
const allAccounts = parsed as Account[]
|
||||
const enabledAccounts = allAccounts.filter(acc => acc.enabled !== false)
|
||||
return enabledAccounts
|
||||
} catch (error) {
|
||||
throw new Error(error as string)
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfigPath(): string { return configSourcePath }
|
||||
|
||||
export function loadConfig(): Config {
|
||||
try {
|
||||
const configDir = path.join(__dirname, '../', 'config.json')
|
||||
const config = fs.readFileSync(configDir, 'utf-8')
|
||||
if (configCache) {
|
||||
return configCache
|
||||
}
|
||||
|
||||
return JSON.parse(config)
|
||||
// Resolve configuration file from common locations
|
||||
const names = ['config.json']
|
||||
const bases = [
|
||||
path.join(__dirname, '../'), // dist root when compiled
|
||||
path.join(__dirname, '../src'), // fallback: running dist but config still in src
|
||||
process.cwd(), // repo root
|
||||
path.join(process.cwd(), 'src'), // repo/src when running ts-node
|
||||
__dirname // dist/util
|
||||
]
|
||||
const candidates: string[] = []
|
||||
for (const base of bases) {
|
||||
for (const name of names) {
|
||||
candidates.push(path.join(base, name))
|
||||
}
|
||||
}
|
||||
let cfgPath: string | null = null
|
||||
for (const p of candidates) {
|
||||
try { if (fs.existsSync(p)) { cfgPath = p; break } } catch { /* ignore */ }
|
||||
}
|
||||
if (!cfgPath) throw new Error(`config.json not found in: ${candidates.join(' | ')}`)
|
||||
const config = fs.readFileSync(cfgPath, 'utf-8')
|
||||
const json = config.replace(/^\uFEFF/, '')
|
||||
const raw = JSON.parse(json)
|
||||
const normalized = normalizeConfig(raw)
|
||||
configCache = normalized // Set as cache
|
||||
configSourcePath = cfgPath
|
||||
|
||||
return normalized
|
||||
} catch (error) {
|
||||
throw new Error(error as string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSessionData(sessionPath: string, email: string, isMobile: boolean, saveFingerprint: ConfigSaveFingerprint) {
|
||||
try {
|
||||
// Fetch cookie file
|
||||
const cookieFile = path.join(__dirname, '../browser/', sessionPath, email, `${isMobile ? 'mobile_cookies' : 'desktop_cookies'}.json`)
|
||||
|
||||
let cookies: Cookie[] = []
|
||||
if (fs.existsSync(cookieFile)) {
|
||||
const cookiesData = await fs.promises.readFile(cookieFile, 'utf-8')
|
||||
cookies = JSON.parse(cookiesData)
|
||||
}
|
||||
|
||||
// Fetch fingerprint file (support both legacy typo "fingerpint" and corrected "fingerprint")
|
||||
const baseDir = path.join(__dirname, '../browser/', sessionPath, email)
|
||||
const legacyFile = path.join(baseDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`)
|
||||
const correctFile = path.join(baseDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`)
|
||||
|
||||
let fingerprint!: BrowserFingerprintWithHeaders
|
||||
const shouldLoad = (saveFingerprint.desktop && !isMobile) || (saveFingerprint.mobile && isMobile)
|
||||
if (shouldLoad) {
|
||||
const chosen = fs.existsSync(correctFile) ? correctFile : (fs.existsSync(legacyFile) ? legacyFile : '')
|
||||
if (chosen) {
|
||||
const fingerprintData = await fs.promises.readFile(chosen, 'utf-8')
|
||||
fingerprint = JSON.parse(fingerprintData)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cookies: cookies,
|
||||
fingerprint: fingerprint
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
throw new Error(error as string)
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveSessionData(sessionPath: string, browser: BrowserContext, email: string, isMobile: boolean): Promise<string> {
|
||||
try {
|
||||
const cookies = await browser.cookies()
|
||||
|
||||
// Fetch path
|
||||
const sessionDir = path.join(__dirname, '../browser/', sessionPath, email)
|
||||
|
||||
// Create session dir
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
await fs.promises.mkdir(sessionDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Save cookies to a file
|
||||
await fs.promises.writeFile(path.join(sessionDir, `${isMobile ? 'mobile_cookies' : 'desktop_cookies'}.json`), JSON.stringify(cookies))
|
||||
|
||||
return sessionDir
|
||||
} catch (error) {
|
||||
throw new Error(error as string)
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveFingerprintData(sessionPath: string, email: string, isMobile: boolean, fingerprint: BrowserFingerprintWithHeaders): Promise<string> {
|
||||
try {
|
||||
// Fetch path
|
||||
const sessionDir = path.join(__dirname, '../browser/', sessionPath, email)
|
||||
|
||||
// Create session dir
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
await fs.promises.mkdir(sessionDir, { recursive: true })
|
||||
}
|
||||
|
||||
// Save fingerprint to files (write both legacy and corrected names for compatibility)
|
||||
const legacy = path.join(sessionDir, `${isMobile ? 'mobile_fingerpint' : 'desktop_fingerpint'}.json`)
|
||||
const correct = path.join(sessionDir, `${isMobile ? 'mobile_fingerprint' : 'desktop_fingerprint'}.json`)
|
||||
const payload = JSON.stringify(fingerprint)
|
||||
await fs.promises.writeFile(correct, payload)
|
||||
try { await fs.promises.writeFile(legacy, payload) } catch { /* ignore */ }
|
||||
|
||||
return sessionDir
|
||||
} catch (error) {
|
||||
throw new Error(error as string)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,249 @@
|
||||
import { Webhook } from './Webhook'
|
||||
import axios from 'axios'
|
||||
import chalk from 'chalk'
|
||||
|
||||
export function log(title: string, message: string, type?: 'log' | 'warn' | 'error') {
|
||||
const currentTime = new Date().toISOString()
|
||||
import { Ntfy } from './Ntfy'
|
||||
import { loadConfig } from './Load'
|
||||
import { DISCORD } from '../constants'
|
||||
|
||||
let str = ''
|
||||
type WebhookBuffer = {
|
||||
lines: string[]
|
||||
sending: boolean
|
||||
timer?: NodeJS.Timeout
|
||||
}
|
||||
|
||||
const webhookBuffers = new Map<string, WebhookBuffer>()
|
||||
|
||||
// Periodic cleanup of old/idle webhook buffers to prevent memory leaks
|
||||
setInterval(() => {
|
||||
const now = Date.now()
|
||||
const BUFFER_MAX_AGE_MS = 3600000 // 1 hour
|
||||
|
||||
for (const [url, buf] of webhookBuffers.entries()) {
|
||||
if (!buf.sending && buf.lines.length === 0) {
|
||||
const lastActivity = (buf as unknown as { lastActivity?: number }).lastActivity || 0
|
||||
if (now - lastActivity > BUFFER_MAX_AGE_MS) {
|
||||
webhookBuffers.delete(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 600000) // Check every 10 minutes
|
||||
|
||||
function getBuffer(url: string): WebhookBuffer {
|
||||
let buf = webhookBuffers.get(url)
|
||||
if (!buf) {
|
||||
buf = { lines: [], sending: false }
|
||||
webhookBuffers.set(url, buf)
|
||||
}
|
||||
// Track last activity for cleanup
|
||||
(buf as unknown as { lastActivity: number }).lastActivity = Date.now()
|
||||
return buf
|
||||
}
|
||||
|
||||
async function sendBatch(url: string, buf: WebhookBuffer) {
|
||||
if (buf.sending) return
|
||||
buf.sending = true
|
||||
|
||||
while (buf.lines.length > 0) {
|
||||
const chunk: string[] = []
|
||||
let currentLength = 0
|
||||
while (buf.lines.length > 0) {
|
||||
const next = buf.lines[0]!
|
||||
const projected = currentLength + next.length + (chunk.length > 0 ? 1 : 0)
|
||||
if (projected > DISCORD.MAX_EMBED_LENGTH && chunk.length > 0) break
|
||||
buf.lines.shift()
|
||||
chunk.push(next)
|
||||
currentLength = projected
|
||||
}
|
||||
|
||||
const content = chunk.join('\n').slice(0, DISCORD.MAX_EMBED_LENGTH)
|
||||
if (!content) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Enhanced webhook payload with embed, username and avatar
|
||||
const payload = {
|
||||
embeds: [{
|
||||
description: `\`\`\`\n${content}\n\`\`\``,
|
||||
color: determineColorFromContent(content),
|
||||
timestamp: new Date().toISOString()
|
||||
}]
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(url, payload, { headers: { 'Content-Type': 'application/json' }, timeout: DISCORD.WEBHOOK_TIMEOUT })
|
||||
await new Promise(resolve => setTimeout(resolve, DISCORD.RATE_LIMIT_DELAY))
|
||||
} catch (error) {
|
||||
// Re-queue failed batch at front and exit loop
|
||||
buf.lines = chunk.concat(buf.lines)
|
||||
console.error('[Webhook] live log delivery failed:', error)
|
||||
break
|
||||
}
|
||||
}
|
||||
buf.sending = false
|
||||
}
|
||||
|
||||
function determineColorFromContent(content: string): number {
|
||||
const lower = content.toLowerCase()
|
||||
// Security/Ban alerts - Red
|
||||
if (lower.includes('[banned]') || lower.includes('[security]') || lower.includes('suspended') || lower.includes('compromised')) {
|
||||
return DISCORD.COLOR_RED
|
||||
}
|
||||
// Errors - Dark Red
|
||||
if (lower.includes('[error]') || lower.includes('✗')) {
|
||||
return DISCORD.COLOR_CRIMSON
|
||||
}
|
||||
// Warnings - Orange/Yellow
|
||||
if (lower.includes('[warn]') || lower.includes('⚠')) {
|
||||
return DISCORD.COLOR_ORANGE
|
||||
}
|
||||
// Success - Green
|
||||
if (lower.includes('[ok]') || lower.includes('✓') || lower.includes('complet')) {
|
||||
return DISCORD.COLOR_GREEN
|
||||
}
|
||||
// Info/Main - Blue
|
||||
if (lower.includes('[main]')) {
|
||||
return DISCORD.COLOR_BLUE
|
||||
}
|
||||
// Default - Gray
|
||||
return 0x95A5A6 // Gray
|
||||
}
|
||||
|
||||
function enqueueWebhookLog(url: string, line: string) {
|
||||
const buf = getBuffer(url)
|
||||
buf.lines.push(line)
|
||||
if (!buf.timer) {
|
||||
buf.timer = setTimeout(() => {
|
||||
buf.timer = undefined
|
||||
void sendBatch(url, buf)
|
||||
}, DISCORD.DEBOUNCE_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
// Synchronous logger that returns an Error when type === 'error' so callers can `throw log(...)` safely.
|
||||
export function log(isMobile: boolean | 'main', title: string, message: string, type: 'log' | 'warn' | 'error' = 'log', color?: keyof typeof chalk): Error | void {
|
||||
const configData = loadConfig()
|
||||
|
||||
// Access logging config with fallback for backward compatibility
|
||||
const configAny = configData as unknown as Record<string, unknown>
|
||||
const logging = configAny.logging as { excludeFunc?: string[]; logExcludeFunc?: string[] } | undefined
|
||||
const logExcludeFunc = logging?.excludeFunc ?? (configData as { logExcludeFunc?: string[] }).logExcludeFunc ?? []
|
||||
|
||||
if (logExcludeFunc.some((x: string) => x.toLowerCase() === title.toLowerCase())) {
|
||||
return
|
||||
}
|
||||
|
||||
const currentTime = new Date().toLocaleString()
|
||||
const platformText = isMobile === 'main' ? 'MAIN' : isMobile ? 'MOBILE' : 'DESKTOP'
|
||||
|
||||
// Clean string for notifications (no chalk, structured)
|
||||
type LoggingCfg = { excludeFunc?: string[]; webhookExcludeFunc?: string[]; redactEmails?: boolean }
|
||||
const loggingCfg: LoggingCfg = (configAny.logging || {}) as LoggingCfg
|
||||
const shouldRedact = !!loggingCfg.redactEmails
|
||||
const redact = (s: string) => shouldRedact ? s.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/ig, (m) => {
|
||||
const [u, d] = m.split('@'); return `${(u || '').slice(0, 2)}***@${d || ''}`
|
||||
}) : s
|
||||
const cleanStr = redact(`[${currentTime}] [PID: ${process.pid}] [${type.toUpperCase()}] ${platformText} [${title}] ${message}`)
|
||||
|
||||
// Define conditions for sending to NTFY
|
||||
const ntfyConditions = {
|
||||
log: [
|
||||
message.toLowerCase().includes('started tasks for account'),
|
||||
message.toLowerCase().includes('press the number'),
|
||||
message.toLowerCase().includes('no points to earn')
|
||||
],
|
||||
error: [],
|
||||
warn: [
|
||||
message.toLowerCase().includes('aborting'),
|
||||
message.toLowerCase().includes('didn\'t gain')
|
||||
]
|
||||
}
|
||||
|
||||
// Check if the current log type and message meet the NTFY conditions
|
||||
try {
|
||||
if (type in ntfyConditions && ntfyConditions[type as keyof typeof ntfyConditions].some(condition => condition)) {
|
||||
// Fire-and-forget
|
||||
Promise.resolve(Ntfy(cleanStr, type)).catch(() => { /* ignore ntfy errors */ })
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Console output with better formatting and contextual icons
|
||||
const typeIndicator = type === 'error' ? '✗' : type === 'warn' ? '⚠' : '✓'
|
||||
const platformColor = isMobile === 'main' ? chalk.cyan : isMobile ? chalk.blue : chalk.magenta
|
||||
const typeColor = type === 'error' ? chalk.red : type === 'warn' ? chalk.yellow : chalk.green
|
||||
|
||||
// Add contextual icon based on title/message (ASCII-safe for Windows PowerShell)
|
||||
const titleLower = title.toLowerCase()
|
||||
const msgLower = message.toLowerCase()
|
||||
|
||||
// ASCII-safe icons for Windows PowerShell compatibility
|
||||
const iconMap: Array<[RegExp, string]> = [
|
||||
[/security|compromised/i, '[SECURITY]'],
|
||||
[/ban|suspend/i, '[BANNED]'],
|
||||
[/error/i, '[ERROR]'],
|
||||
[/warn/i, '[WARN]'],
|
||||
[/success|complet/i, '[OK]'],
|
||||
[/login/i, '[LOGIN]'],
|
||||
[/point/i, '[POINTS]'],
|
||||
[/search/i, '[SEARCH]'],
|
||||
[/activity|quiz|poll/i, '[ACTIVITY]'],
|
||||
[/browser/i, '[BROWSER]'],
|
||||
[/main/i, '[MAIN]']
|
||||
]
|
||||
|
||||
let icon = ''
|
||||
for (const [pattern, symbol] of iconMap) {
|
||||
if (pattern.test(titleLower) || pattern.test(msgLower)) {
|
||||
icon = chalk.dim(symbol)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const iconPart = icon ? icon + ' ' : ''
|
||||
|
||||
const formattedStr = [
|
||||
chalk.gray(`[${currentTime}]`),
|
||||
chalk.gray(`[${process.pid}]`),
|
||||
typeColor(`${typeIndicator}`),
|
||||
platformColor(`[${platformText}]`),
|
||||
chalk.bold(`[${title}]`),
|
||||
iconPart + redact(message)
|
||||
].join(' ')
|
||||
|
||||
const applyChalk = color && typeof chalk[color] === 'function' ? chalk[color] as (msg: string) => string : null
|
||||
|
||||
// Log based on the type
|
||||
switch (type) {
|
||||
case 'warn':
|
||||
str = `[${currentTime}] [PID: ${process.pid}] [WARN] [${title}] ${message}`
|
||||
console.warn(str)
|
||||
applyChalk ? console.warn(applyChalk(formattedStr)) : console.warn(formattedStr)
|
||||
break
|
||||
|
||||
case 'error':
|
||||
str = `[${currentTime}] [PID: ${process.pid}] [ERROR] [${title}] ${message}`
|
||||
console.error(str)
|
||||
applyChalk ? console.error(applyChalk(formattedStr)) : console.error(formattedStr)
|
||||
break
|
||||
|
||||
default:
|
||||
str = `[${currentTime}] [PID: ${process.pid}] [LOG] [${title}] ${message}`
|
||||
console.log(str)
|
||||
applyChalk ? console.log(applyChalk(formattedStr)) : console.log(formattedStr)
|
||||
break
|
||||
}
|
||||
|
||||
if (str) Webhook(str)
|
||||
// Webhook streaming (live logs)
|
||||
try {
|
||||
const loggingCfg: Record<string, unknown> = (configAny.logging || {}) as Record<string, unknown>
|
||||
const webhookCfg = configData.webhook
|
||||
const liveUrlRaw = typeof loggingCfg.liveWebhookUrl === 'string' ? loggingCfg.liveWebhookUrl.trim() : ''
|
||||
const liveUrl = liveUrlRaw || (webhookCfg?.enabled && webhookCfg.url ? webhookCfg.url : '')
|
||||
const webhookExclude = Array.isArray(loggingCfg.webhookExcludeFunc) ? loggingCfg.webhookExcludeFunc : configData.webhookLogExcludeFunc || []
|
||||
const webhookExcluded = Array.isArray(webhookExclude) && webhookExclude.some((x: string) => x.toLowerCase() === title.toLowerCase())
|
||||
if (liveUrl && !webhookExcluded) {
|
||||
enqueueWebhookLog(liveUrl, cleanStr)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Logger] Failed to enqueue webhook log:', error)
|
||||
}
|
||||
|
||||
// Return an Error when logging an error so callers can `throw log(...)`
|
||||
if (type === 'error') {
|
||||
// CommunityReporter disabled per project policy
|
||||
return new Error(cleanStr)
|
||||
}
|
||||
}
|
||||
27
src/util/Ntfy.ts
Normal file
27
src/util/Ntfy.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { loadConfig } from './Load'
|
||||
import axios from 'axios'
|
||||
|
||||
const NOTIFICATION_TYPES = {
|
||||
error: { priority: 'max', tags: 'rotating_light' }, // Customize the ERROR icon here, see: https://docs.ntfy.sh/emojis/
|
||||
warn: { priority: 'high', tags: 'warning' }, // Customize the WARN icon here, see: https://docs.ntfy.sh/emojis/
|
||||
log: { priority: 'default', tags: 'medal_sports' } // Customize the LOG icon here, see: https://docs.ntfy.sh/emojis/
|
||||
}
|
||||
|
||||
export async function Ntfy(message: string, type: keyof typeof NOTIFICATION_TYPES = 'log'): Promise<void> {
|
||||
const config = loadConfig().ntfy
|
||||
if (!config?.enabled || !config.url || !config.topic) return
|
||||
|
||||
try {
|
||||
const { priority, tags } = NOTIFICATION_TYPES[type]
|
||||
const headers = {
|
||||
Title: 'Microsoft Rewards Script',
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
...(config.authToken && { Authorization: `Bearer ${config.authToken}` })
|
||||
}
|
||||
|
||||
await axios.post(`${config.url}/${config.topic}`, message, { headers })
|
||||
} catch (error) {
|
||||
// Silently fail - NTFY is a non-critical notification service
|
||||
}
|
||||
}
|
||||
340
src/util/QueryDiversityEngine.ts
Normal file
340
src/util/QueryDiversityEngine.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export interface QuerySource {
|
||||
name: string
|
||||
weight: number // 0-1, probability of selection
|
||||
fetchQueries: () => Promise<string[]>
|
||||
}
|
||||
|
||||
export interface QueryDiversityConfig {
|
||||
sources: Array<'google-trends' | 'reddit' | 'news' | 'wikipedia' | 'local-fallback'>
|
||||
deduplicate: boolean
|
||||
mixStrategies: boolean // Mix different source types in same session
|
||||
maxQueriesPerSource: number
|
||||
cacheMinutes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryDiversityEngine fetches search queries from multiple sources to avoid patterns.
|
||||
* Supports Google Trends, Reddit, News APIs, Wikipedia, and local fallbacks.
|
||||
*/
|
||||
export class QueryDiversityEngine {
|
||||
private config: QueryDiversityConfig
|
||||
private cache: Map<string, { queries: string[]; expires: number }> = new Map()
|
||||
|
||||
constructor(config?: Partial<QueryDiversityConfig>) {
|
||||
this.config = {
|
||||
sources: config?.sources || ['google-trends', 'reddit', 'local-fallback'],
|
||||
deduplicate: config?.deduplicate !== false,
|
||||
mixStrategies: config?.mixStrategies !== false,
|
||||
maxQueriesPerSource: config?.maxQueriesPerSource || 10,
|
||||
cacheMinutes: config?.cacheMinutes || 30
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch diverse queries from configured sources
|
||||
*/
|
||||
async fetchQueries(count: number): Promise<string[]> {
|
||||
const allQueries: string[] = []
|
||||
|
||||
for (const sourceName of this.config.sources) {
|
||||
try {
|
||||
const queries = await this.getFromSource(sourceName)
|
||||
allQueries.push(...queries.slice(0, this.config.maxQueriesPerSource))
|
||||
} catch (error) {
|
||||
// Silently fail and try other sources
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
let final = this.config.deduplicate ? Array.from(new Set(allQueries)) : allQueries
|
||||
|
||||
// Mix strategies: interleave queries from different sources
|
||||
if (this.config.mixStrategies && this.config.sources.length > 1) {
|
||||
final = this.interleaveQueries(final, count)
|
||||
}
|
||||
|
||||
// Shuffle and limit to requested count
|
||||
final = this.shuffleArray(final).slice(0, count)
|
||||
|
||||
return final.length > 0 ? final : this.getLocalFallback(count)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from a specific source with caching
|
||||
*/
|
||||
private async getFromSource(source: string): Promise<string[]> {
|
||||
const cached = this.cache.get(source)
|
||||
if (cached && Date.now() < cached.expires) {
|
||||
return cached.queries
|
||||
}
|
||||
|
||||
let queries: string[] = []
|
||||
|
||||
switch (source) {
|
||||
case 'google-trends':
|
||||
queries = await this.fetchGoogleTrends()
|
||||
break
|
||||
case 'reddit':
|
||||
queries = await this.fetchReddit()
|
||||
break
|
||||
case 'news':
|
||||
queries = await this.fetchNews()
|
||||
break
|
||||
case 'wikipedia':
|
||||
queries = await this.fetchWikipedia()
|
||||
break
|
||||
case 'local-fallback':
|
||||
queries = this.getLocalFallback(20)
|
||||
break
|
||||
default:
|
||||
// Unknown source, skip silently
|
||||
break
|
||||
}
|
||||
|
||||
this.cache.set(source, {
|
||||
queries,
|
||||
expires: Date.now() + (this.config.cacheMinutes * 60000)
|
||||
})
|
||||
|
||||
return queries
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from Google Trends (existing logic can be reused)
|
||||
*/
|
||||
private async fetchGoogleTrends(): Promise<string[]> {
|
||||
try {
|
||||
const response = await axios.get('https://trends.google.com/trends/api/dailytrends?geo=US', {
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
const data = response.data.toString().replace(')]}\',', '')
|
||||
const parsed = JSON.parse(data)
|
||||
|
||||
const queries: string[] = []
|
||||
for (const item of parsed.default.trendingSearchesDays || []) {
|
||||
for (const search of item.trendingSearches || []) {
|
||||
if (search.title?.query) {
|
||||
queries.push(search.title.query)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return queries.slice(0, 20)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from Reddit (top posts from popular subreddits)
|
||||
*/
|
||||
private async fetchReddit(): Promise<string[]> {
|
||||
try {
|
||||
const subreddits = ['news', 'worldnews', 'todayilearned', 'askreddit', 'technology']
|
||||
const randomSub = subreddits[Math.floor(Math.random() * subreddits.length)]
|
||||
|
||||
const response = await axios.get(`https://www.reddit.com/r/${randomSub}/hot.json?limit=15`, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
})
|
||||
|
||||
const posts = response.data.data.children || []
|
||||
const queries: string[] = []
|
||||
|
||||
for (const post of posts) {
|
||||
const title = post.data?.title
|
||||
if (title && title.length > 10 && title.length < 100) {
|
||||
queries.push(title)
|
||||
}
|
||||
}
|
||||
|
||||
return queries
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from News API (requires API key - fallback to headlines scraping)
|
||||
*/
|
||||
private async fetchNews(): Promise<string[]> {
|
||||
try {
|
||||
// Using NewsAPI.org free tier (limited requests)
|
||||
const apiKey = process.env.NEWS_API_KEY
|
||||
if (!apiKey) {
|
||||
return this.fetchNewsFallback()
|
||||
}
|
||||
|
||||
const response = await axios.get('https://newsapi.org/v2/top-headlines', {
|
||||
params: {
|
||||
country: 'us',
|
||||
pageSize: 15,
|
||||
apiKey
|
||||
},
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
const articles = response.data.articles || []
|
||||
return articles.map((a: { title?: string }) => a.title).filter((t: string | undefined) => t && t.length > 10)
|
||||
} catch {
|
||||
return this.fetchNewsFallback()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback news scraper (BBC/CNN headlines)
|
||||
*/
|
||||
private async fetchNewsFallback(): Promise<string[]> {
|
||||
try {
|
||||
const response = await axios.get('https://www.bbc.com/news', {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
})
|
||||
|
||||
const html = response.data
|
||||
const regex = /<h3[^>]*>(.*?)<\/h3>/gi
|
||||
const matches: RegExpMatchArray[] = []
|
||||
let match
|
||||
while ((match = regex.exec(html)) !== null) {
|
||||
matches.push(match)
|
||||
}
|
||||
|
||||
return matches
|
||||
.map(m => m[1]?.replace(/<[^>]+>/g, '').trim())
|
||||
.filter((t: string | undefined) => t && t.length > 10 && t.length < 100)
|
||||
.slice(0, 10) as string[]
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch from Wikipedia (featured articles / trending topics)
|
||||
*/
|
||||
private async fetchWikipedia(): Promise<string[]> {
|
||||
try {
|
||||
const response = await axios.get('https://en.wikipedia.org/w/api.php', {
|
||||
params: {
|
||||
action: 'query',
|
||||
list: 'random',
|
||||
rnnamespace: 0,
|
||||
rnlimit: 15,
|
||||
format: 'json'
|
||||
},
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
const pages = response.data.query?.random || []
|
||||
return pages.map((p: { title?: string }) => p.title).filter((t: string | undefined) => t && t.length > 3)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Local fallback queries (curated list)
|
||||
*/
|
||||
private getLocalFallback(count: number): string[] {
|
||||
const fallback = [
|
||||
'weather forecast',
|
||||
'news today',
|
||||
'stock market',
|
||||
'sports scores',
|
||||
'movie reviews',
|
||||
'recipes',
|
||||
'travel destinations',
|
||||
'health tips',
|
||||
'technology news',
|
||||
'best restaurants near me',
|
||||
'how to cook pasta',
|
||||
'python tutorial',
|
||||
'world events',
|
||||
'climate change',
|
||||
'electric vehicles',
|
||||
'space exploration',
|
||||
'artificial intelligence',
|
||||
'cryptocurrency',
|
||||
'gaming news',
|
||||
'fashion trends',
|
||||
'fitness workout',
|
||||
'home improvement',
|
||||
'gardening tips',
|
||||
'pet care',
|
||||
'book recommendations',
|
||||
'music charts',
|
||||
'streaming shows',
|
||||
'historical events',
|
||||
'science discoveries',
|
||||
'education resources'
|
||||
]
|
||||
|
||||
return this.shuffleArray(fallback).slice(0, count)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interleave queries from different sources for diversity
|
||||
*/
|
||||
private interleaveQueries(queries: string[], targetCount: number): string[] {
|
||||
const result: string[] = []
|
||||
const sourceMap = new Map<string, string[]>()
|
||||
|
||||
// Group queries by estimated source (simple heuristic)
|
||||
for (const q of queries) {
|
||||
const source = this.guessSource(q)
|
||||
if (!sourceMap.has(source)) {
|
||||
sourceMap.set(source, [])
|
||||
}
|
||||
sourceMap.get(source)?.push(q)
|
||||
}
|
||||
|
||||
const sources = Array.from(sourceMap.values())
|
||||
let index = 0
|
||||
|
||||
while (result.length < targetCount && sources.some(s => s.length > 0)) {
|
||||
const source = sources[index % sources.length]
|
||||
if (source && source.length > 0) {
|
||||
const q = source.shift()
|
||||
if (q) result.push(q)
|
||||
}
|
||||
index++
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Guess which source a query came from (basic heuristic)
|
||||
*/
|
||||
private guessSource(query: string): string {
|
||||
if (/^[A-Z]/.test(query) && query.includes(' ')) return 'news'
|
||||
if (query.length > 80) return 'reddit'
|
||||
if (/how to|what is|why/i.test(query)) return 'local'
|
||||
return 'trends'
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffle array (Fisher-Yates)
|
||||
*/
|
||||
private shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffled = [...array]
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
|
||||
}
|
||||
return shuffled
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache (call between runs)
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.cache.clear()
|
||||
}
|
||||
}
|
||||
63
src/util/Retry.ts
Normal file
63
src/util/Retry.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { ConfigRetryPolicy } from '../interface/Config'
|
||||
import Util from './Utils'
|
||||
|
||||
type NumericPolicy = {
|
||||
maxAttempts: number
|
||||
baseDelay: number
|
||||
maxDelay: number
|
||||
multiplier: number
|
||||
jitter: number
|
||||
}
|
||||
|
||||
export type Retryable<T> = () => Promise<T>
|
||||
|
||||
export class Retry {
|
||||
private policy: NumericPolicy
|
||||
|
||||
constructor(policy?: ConfigRetryPolicy) {
|
||||
const def: NumericPolicy = {
|
||||
maxAttempts: 3,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 30000,
|
||||
multiplier: 2,
|
||||
jitter: 0.2
|
||||
}
|
||||
const merged: ConfigRetryPolicy = { ...(policy || {}) }
|
||||
// normalize string durations
|
||||
const util = new Util()
|
||||
const parse = (v: number | string) => {
|
||||
if (typeof v === 'number') return v
|
||||
try { return util.stringToMs(String(v)) } catch { return def.baseDelay }
|
||||
}
|
||||
this.policy = {
|
||||
maxAttempts: (merged.maxAttempts as number) ?? def.maxAttempts,
|
||||
baseDelay: parse(merged.baseDelay ?? def.baseDelay),
|
||||
maxDelay: parse(merged.maxDelay ?? def.maxDelay),
|
||||
multiplier: (merged.multiplier as number) ?? def.multiplier,
|
||||
jitter: (merged.jitter as number) ?? def.jitter
|
||||
}
|
||||
}
|
||||
|
||||
async run<T>(fn: Retryable<T>, isRetryable?: (e: unknown) => boolean): Promise<T> {
|
||||
let attempt = 0
|
||||
let delay = this.policy.baseDelay
|
||||
let lastErr: unknown
|
||||
while (attempt < this.policy.maxAttempts) {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (e) {
|
||||
lastErr = e
|
||||
attempt += 1
|
||||
const retry = isRetryable ? isRetryable(e) : true
|
||||
if (!retry || attempt >= this.policy.maxAttempts) break
|
||||
const jitter = 1 + (Math.random() * 2 - 1) * this.policy.jitter
|
||||
const sleep = Math.min(this.policy.maxDelay, Math.max(0, Math.floor(delay * jitter)))
|
||||
await new Promise((r) => setTimeout(r, sleep))
|
||||
delay = Math.min(this.policy.maxDelay, Math.floor(delay * (this.policy.multiplier || 2)))
|
||||
}
|
||||
}
|
||||
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr))
|
||||
}
|
||||
}
|
||||
|
||||
export default Retry
|
||||
84
src/util/Totp.ts
Normal file
84
src/util/Totp.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import crypto from 'crypto'
|
||||
|
||||
/**
|
||||
* Decode Base32 (RFC 4648) to a Buffer.
|
||||
* Accepts lowercase/uppercase, optional padding.
|
||||
*/
|
||||
function base32Decode(input: string): Buffer {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
|
||||
const clean = input.toUpperCase().replace(/=+$/g, '').replace(/[^A-Z2-7]/g, '')
|
||||
let bits = 0
|
||||
let value = 0
|
||||
const bytes: number[] = []
|
||||
|
||||
for (const char of clean) {
|
||||
const idx = alphabet.indexOf(char)
|
||||
if (idx < 0) continue
|
||||
value = (value << 5) | idx
|
||||
bits += 5
|
||||
if (bits >= 8) {
|
||||
bits -= 8
|
||||
bytes.push((value >>> bits) & 0xff)
|
||||
}
|
||||
}
|
||||
return Buffer.from(bytes)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an HMAC using Node's crypto and return Buffer.
|
||||
*/
|
||||
function hmac(algorithm: string, key: Buffer, data: Buffer): Buffer {
|
||||
return crypto.createHmac(algorithm, key).update(data).digest()
|
||||
}
|
||||
|
||||
export type TotpOptions = { digits?: number; step?: number; algorithm?: 'SHA1' | 'SHA256' | 'SHA512' }
|
||||
|
||||
/**
|
||||
* Generate TOTP per RFC 6238.
|
||||
* @param secretBase32 - shared secret in Base32
|
||||
* @param time - Unix time in seconds (defaults to now)
|
||||
* @param options - { digits, step, algorithm }
|
||||
* @returns numeric TOTP as string (zero-padded)
|
||||
*/
|
||||
export function generateTOTP(
|
||||
secretBase32: string,
|
||||
time: number = Math.floor(Date.now() / 1000),
|
||||
options?: TotpOptions
|
||||
): string {
|
||||
const digits = options?.digits ?? 6
|
||||
const step = options?.step ?? 30
|
||||
const alg = (options?.algorithm ?? 'SHA1').toUpperCase()
|
||||
|
||||
const key = base32Decode(secretBase32)
|
||||
const counter = Math.floor(time / step)
|
||||
|
||||
// 8-byte big-endian counter
|
||||
const counterBuffer = Buffer.alloc(8)
|
||||
counterBuffer.writeBigUInt64BE(BigInt(counter), 0)
|
||||
|
||||
let hmacAlg: string
|
||||
if (alg === 'SHA1') hmacAlg = 'sha1'
|
||||
else if (alg === 'SHA256') hmacAlg = 'sha256'
|
||||
else if (alg === 'SHA512') hmacAlg = 'sha512'
|
||||
else throw new Error('Unsupported algorithm. Use SHA1, SHA256 or SHA512.')
|
||||
|
||||
const hash = hmac(hmacAlg, key, counterBuffer)
|
||||
if (!hash || hash.length < 20) {
|
||||
// Minimal sanity check; for SHA1 length is 20
|
||||
throw new Error('Invalid HMAC output for TOTP')
|
||||
}
|
||||
|
||||
// Dynamic truncation
|
||||
const offset = hash[hash.length - 1]! & 0x0f
|
||||
if (offset + 3 >= hash.length) {
|
||||
throw new Error('Invalid dynamic truncation offset')
|
||||
}
|
||||
const code =
|
||||
((hash[offset]! & 0x7f) << 24) |
|
||||
((hash[offset + 1]! & 0xff) << 16) |
|
||||
((hash[offset + 2]! & 0xff) << 8) |
|
||||
(hash[offset + 3]! & 0xff)
|
||||
|
||||
const otp = (code % 10 ** digits).toString().padStart(digits, '0')
|
||||
return otp
|
||||
}
|
||||
@@ -1,42 +1,58 @@
|
||||
import axios from 'axios'
|
||||
import { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
||||
|
||||
import { log } from './Logger'
|
||||
import Retry from './Retry'
|
||||
|
||||
import { ChromeVersion, EdgeVersion } from '../interface/UserAgentUtil'
|
||||
import { ChromeVersion, EdgeVersion, Architecture, Platform } from '../interface/UserAgentUtil'
|
||||
|
||||
export async function getUserAgent(mobile: boolean) {
|
||||
const system = getSystemComponents(mobile)
|
||||
const app = await getAppComponents(mobile)
|
||||
const NOT_A_BRAND_VERSION = '99'
|
||||
const EDGE_VERSION_URL = 'https://edgeupdates.microsoft.com/api/products'
|
||||
const EDGE_VERSION_CACHE_TTL_MS = 1000 * 60 * 60
|
||||
|
||||
const uaTemplate = mobile ?
|
||||
type EdgeVersionResult = {
|
||||
android?: string
|
||||
windows?: string
|
||||
}
|
||||
|
||||
let edgeVersionCache: { data: EdgeVersionResult; expiresAt: number } | null = null
|
||||
let edgeVersionInFlight: Promise<EdgeVersionResult> | null = null
|
||||
|
||||
export async function getUserAgent(isMobile: boolean) {
|
||||
const system = getSystemComponents(isMobile)
|
||||
const app = await getAppComponents(isMobile)
|
||||
|
||||
const uaTemplate = isMobile ?
|
||||
`Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Mobile Safari/537.36 EdgA/${app.edge_version}` :
|
||||
`Mozilla/5.0 (${system}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${app.chrome_reduced_version} Safari/537.36 Edg/${app.edge_version}`
|
||||
|
||||
const platformVersion = `${mobile ? Math.floor(Math.random() * 5) + 9 : Math.floor(Math.random() * 15) + 1}.0.0`
|
||||
const platformVersion = `${isMobile ? Math.floor(Math.random() * 5) + 9 : Math.floor(Math.random() * 15) + 1}.0.0`
|
||||
|
||||
const uaMetadata = {
|
||||
mobile,
|
||||
platform: mobile ? 'Android' : 'Windows',
|
||||
mobile: isMobile,
|
||||
isMobile,
|
||||
platform: isMobile ? 'Android' : 'Windows',
|
||||
fullVersionList: [
|
||||
{ brand: 'Not/A)Brand', version: '99.0.0.0' },
|
||||
{ brand: 'Not/A)Brand', version: `${NOT_A_BRAND_VERSION}.0.0.0` },
|
||||
{ brand: 'Microsoft Edge', version: app['edge_version'] },
|
||||
{ brand: 'Chromium', version: app['chrome_version'] }
|
||||
],
|
||||
brands: [
|
||||
{ brand: 'Not/A)Brand', version: '99' },
|
||||
{ brand: 'Not/A)Brand', version: NOT_A_BRAND_VERSION },
|
||||
{ brand: 'Microsoft Edge', version: app['edge_major_version'] },
|
||||
{ brand: 'Chromium', version: app['chrome_major_version'] }
|
||||
],
|
||||
platformVersion,
|
||||
architecture: mobile ? '' : 'x86',
|
||||
bitness: mobile ? '' : '64',
|
||||
model: ''
|
||||
architecture: isMobile ? '' : 'x86',
|
||||
bitness: isMobile ? '' : '64',
|
||||
model: '',
|
||||
uaFullVersion: app['chrome_version']
|
||||
}
|
||||
|
||||
return { userAgent: uaTemplate, userAgentMetadata: uaMetadata }
|
||||
}
|
||||
|
||||
export async function getChromeVersion(): Promise<string> {
|
||||
export async function getChromeVersion(isMobile: boolean): Promise<string> {
|
||||
try {
|
||||
const request = {
|
||||
url: 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json',
|
||||
@@ -51,59 +67,216 @@ export async function getChromeVersion(): Promise<string> {
|
||||
return data.channels.Stable.version
|
||||
|
||||
} catch (error) {
|
||||
throw log('USERAGENT-CHROME-VERSION', 'An error occurred:' + error, 'error')
|
||||
throw log(isMobile, 'USERAGENT-CHROME-VERSION', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEdgeVersions() {
|
||||
try {
|
||||
const request = {
|
||||
url: 'https://edgeupdates.microsoft.com/api/products',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios(request)
|
||||
const data: EdgeVersion[] = response.data
|
||||
const stable = data.find(x => x.Product == 'Stable') as EdgeVersion
|
||||
return {
|
||||
android: stable.Releases.find(x => x.Platform == 'Android')?.ProductVersion,
|
||||
windows: stable.Releases.find(x => (x.Platform == 'Windows' && x.Architecture == 'x64'))?.ProductVersion
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
throw log('USERAGENT-EDGE-VERSION', 'An error occurred:' + error, 'error')
|
||||
export async function getEdgeVersions(isMobile: boolean) {
|
||||
const now = Date.now()
|
||||
if (edgeVersionCache && edgeVersionCache.expiresAt > now) {
|
||||
return edgeVersionCache.data
|
||||
}
|
||||
|
||||
if (edgeVersionInFlight) {
|
||||
try {
|
||||
return await edgeVersionInFlight
|
||||
} catch (error) {
|
||||
if (edgeVersionCache) {
|
||||
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Using cached Edge versions after in-flight failure: ' + formatEdgeError(error), 'warn')
|
||||
return edgeVersionCache.data
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPromise = fetchEdgeVersionsWithRetry(isMobile)
|
||||
.then(result => {
|
||||
edgeVersionCache = { data: result, expiresAt: Date.now() + EDGE_VERSION_CACHE_TTL_MS }
|
||||
edgeVersionInFlight = null
|
||||
return result
|
||||
})
|
||||
.catch(error => {
|
||||
edgeVersionInFlight = null
|
||||
if (edgeVersionCache) {
|
||||
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Falling back to cached Edge versions: ' + formatEdgeError(error), 'warn')
|
||||
return edgeVersionCache.data
|
||||
}
|
||||
throw log(isMobile, 'USERAGENT-EDGE-VERSION', 'Failed to fetch Edge versions: ' + formatEdgeError(error), 'error')
|
||||
})
|
||||
|
||||
edgeVersionInFlight = fetchPromise
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
export function getSystemComponents(mobile: boolean): string {
|
||||
const osId: string = mobile ? 'Linux' : 'Windows NT 10.0'
|
||||
const uaPlatform: string = mobile ? 'Android 13' : 'Win64; x64'
|
||||
|
||||
if (mobile) {
|
||||
return `${uaPlatform}; ${osId}; K`
|
||||
const androidVersion = 10 + Math.floor(Math.random() * 5)
|
||||
return `Linux; Android ${androidVersion}; K`
|
||||
}
|
||||
|
||||
return `${uaPlatform}; ${osId}`
|
||||
return 'Windows NT 10.0; Win64; x64'
|
||||
}
|
||||
|
||||
export async function getAppComponents(mobile: boolean) {
|
||||
const versions = await getEdgeVersions()
|
||||
const edgeVersion = mobile ? versions.android : versions.windows as string
|
||||
export async function getAppComponents(isMobile: boolean) {
|
||||
const versions = await getEdgeVersions(isMobile)
|
||||
const edgeVersion = isMobile ? versions.android : versions.windows as string
|
||||
const edgeMajorVersion = edgeVersion?.split('.')[0]
|
||||
|
||||
const chromeVersion = await getChromeVersion()
|
||||
const chromeVersion = await getChromeVersion(isMobile)
|
||||
const chromeMajorVersion = chromeVersion?.split('.')[0]
|
||||
const chromeReducedVersion = `${chromeMajorVersion}.0.0.0`
|
||||
|
||||
return {
|
||||
not_a_brand_version: `${NOT_A_BRAND_VERSION}.0.0.0`,
|
||||
not_a_brand_major_version: NOT_A_BRAND_VERSION,
|
||||
edge_version: edgeVersion as string,
|
||||
edge_major_version: edgeMajorVersion as string,
|
||||
chrome_version: chromeVersion as string,
|
||||
chrome_major_version: chromeMajorVersion as string,
|
||||
chrome_reduced_version: chromeReducedVersion as string
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEdgeVersionsWithRetry(isMobile: boolean): Promise<EdgeVersionResult> {
|
||||
const retry = new Retry()
|
||||
return retry.run(async () => {
|
||||
const versions = await fetchEdgeVersionsOnce(isMobile)
|
||||
if (!versions.android && !versions.windows) {
|
||||
throw new Error('Stable Edge releases did not include Android or Windows versions')
|
||||
}
|
||||
return versions
|
||||
}, () => true)
|
||||
}
|
||||
|
||||
async function fetchEdgeVersionsOnce(isMobile: boolean): Promise<EdgeVersionResult> {
|
||||
try {
|
||||
const response = await axios<EdgeVersion[]>({
|
||||
url: EDGE_VERSION_URL,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; rewards-bot/2.1)' // Provide UA to avoid stricter servers
|
||||
},
|
||||
timeout: 10000
|
||||
})
|
||||
return mapEdgeVersions(response.data)
|
||||
|
||||
} catch (primaryError) {
|
||||
const fallback = await tryNativeFetchFallback(isMobile)
|
||||
if (fallback) {
|
||||
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Axios failed, native fetch succeeded: ' + formatEdgeError(primaryError), 'warn')
|
||||
return fallback
|
||||
}
|
||||
throw primaryError
|
||||
}
|
||||
}
|
||||
|
||||
async function tryNativeFetchFallback(isMobile: boolean): Promise<EdgeVersionResult | null> {
|
||||
let timeoutHandle: NodeJS.Timeout | undefined
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
timeoutHandle = setTimeout(() => controller.abort(), 10000)
|
||||
const response = await fetch(EDGE_VERSION_URL, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; rewards-bot/2.1)'
|
||||
},
|
||||
signal: controller.signal
|
||||
})
|
||||
clearTimeout(timeoutHandle)
|
||||
timeoutHandle = undefined
|
||||
if (!response.ok) {
|
||||
throw new Error('HTTP ' + response.status)
|
||||
}
|
||||
const data = await response.json() as EdgeVersion[]
|
||||
return mapEdgeVersions(data)
|
||||
} catch (error) {
|
||||
if (timeoutHandle) clearTimeout(timeoutHandle)
|
||||
log(isMobile, 'USERAGENT-EDGE-VERSION', 'Native fetch fallback failed: ' + formatEdgeError(error), 'warn')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function mapEdgeVersions(data: EdgeVersion[]): EdgeVersionResult {
|
||||
const stable = data.find(entry => entry.Product.toLowerCase() === 'stable')
|
||||
?? data.find(entry => /stable/i.test(entry.Product))
|
||||
if (!stable) {
|
||||
throw new Error('Stable Edge channel not found in response payload')
|
||||
}
|
||||
|
||||
const androidRelease = stable.Releases.find(release => release.Platform === Platform.Android)
|
||||
const windowsRelease = stable.Releases.find(release => release.Platform === Platform.Windows && release.Architecture === Architecture.X64)
|
||||
?? stable.Releases.find(release => release.Platform === Platform.Windows)
|
||||
|
||||
return {
|
||||
android: androidRelease?.ProductVersion,
|
||||
windows: windowsRelease?.ProductVersion
|
||||
}
|
||||
}
|
||||
|
||||
function formatEdgeError(error: unknown): string {
|
||||
if (isAggregateErrorLike(error)) {
|
||||
const inner = error.errors
|
||||
.map(innerErr => formatEdgeError(innerErr))
|
||||
.filter(Boolean)
|
||||
.join('; ')
|
||||
const message = error.message || 'AggregateError'
|
||||
return inner ? `${message} | causes: ${inner}` : message
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
const parts = [`${error.name}: ${error.message}`]
|
||||
const cause = getErrorCause(error)
|
||||
if (cause) {
|
||||
parts.push('cause => ' + formatEdgeError(cause))
|
||||
}
|
||||
return parts.join(' | ')
|
||||
}
|
||||
|
||||
return String(error)
|
||||
}
|
||||
|
||||
type AggregateErrorLike = { message?: string; errors: unknown[] }
|
||||
|
||||
function isAggregateErrorLike(error: unknown): error is AggregateErrorLike {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return false
|
||||
}
|
||||
const candidate = error as { errors?: unknown }
|
||||
return Array.isArray(candidate.errors)
|
||||
}
|
||||
|
||||
function getErrorCause(error: { cause?: unknown } | Error): unknown {
|
||||
if (typeof (error as { cause?: unknown }).cause === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
return (error as { cause?: unknown }).cause
|
||||
}
|
||||
|
||||
export async function updateFingerprintUserAgent(fingerprint: BrowserFingerprintWithHeaders, isMobile: boolean): Promise<BrowserFingerprintWithHeaders> {
|
||||
try {
|
||||
const userAgentData = await getUserAgent(isMobile)
|
||||
const componentData = await getAppComponents(isMobile)
|
||||
|
||||
fingerprint.fingerprint.navigator.userAgentData = userAgentData.userAgentMetadata
|
||||
fingerprint.fingerprint.navigator.userAgent = userAgentData.userAgent
|
||||
fingerprint.fingerprint.navigator.appVersion = userAgentData.userAgent.replace(`${fingerprint.fingerprint.navigator.appCodeName}/`, '')
|
||||
|
||||
fingerprint.headers['user-agent'] = userAgentData.userAgent
|
||||
fingerprint.headers['sec-ch-ua'] = `"Microsoft Edge";v="${componentData.edge_major_version}", "Not=A?Brand";v="${componentData.not_a_brand_major_version}", "Chromium";v="${componentData.chrome_major_version}"`
|
||||
fingerprint.headers['sec-ch-ua-full-version-list'] = `"Microsoft Edge";v="${componentData.edge_version}", "Not=A?Brand";v="${componentData.not_a_brand_version}", "Chromium";v="${componentData.chrome_version}"`
|
||||
|
||||
/*
|
||||
Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Mobile Safari/537.36 EdgA/129.0.0.0
|
||||
sec-ch-ua-full-version-list: "Microsoft Edge";v="129.0.2792.84", "Not=A?Brand";v="8.0.0.0", "Chromium";v="129.0.6668.90"
|
||||
sec-ch-ua: "Microsoft Edge";v="129", "Not=A?Brand";v="8", "Chromium";v="129"
|
||||
|
||||
Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36
|
||||
"Google Chrome";v="129.0.6668.90", "Not=A?Brand";v="8.0.0.0", "Chromium";v="129.0.6668.90"
|
||||
*/
|
||||
|
||||
return fingerprint
|
||||
} catch (error) {
|
||||
throw log(isMobile, 'USER-AGENT-UPDATE', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,26 @@
|
||||
import ms from 'ms'
|
||||
|
||||
export default class Util {
|
||||
|
||||
async wait(ms: number): Promise<void> {
|
||||
// Safety check: prevent extremely long or negative waits
|
||||
const MAX_WAIT_MS = 3600000 // 1 hour max
|
||||
const safeMs = Math.min(Math.max(0, ms), MAX_WAIT_MS)
|
||||
|
||||
if (ms !== safeMs) {
|
||||
console.warn(`[Utils] wait() clamped from ${ms}ms to ${safeMs}ms (max: ${MAX_WAIT_MS}ms)`)
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
setTimeout(resolve, safeMs)
|
||||
})
|
||||
}
|
||||
|
||||
async waitRandom(minMs: number, maxMs: number): Promise<void> {
|
||||
const delta = this.randomNumber(minMs, maxMs)
|
||||
return this.wait(delta)
|
||||
}
|
||||
|
||||
getFormattedDate(ms = Date.now()): string {
|
||||
const today = new Date(ms)
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0') // January is 0
|
||||
@@ -16,11 +31,9 @@ export default class Util {
|
||||
}
|
||||
|
||||
shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffledArray = array.slice()
|
||||
|
||||
shuffledArray.sort(() => Math.random() - 0.5)
|
||||
|
||||
return shuffledArray
|
||||
return array.map(value => ({ value, sort: Math.random() }))
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.map(({ value }) => value)
|
||||
}
|
||||
|
||||
randomNumber(min: number, max: number): number {
|
||||
@@ -28,7 +41,17 @@ export default class Util {
|
||||
}
|
||||
|
||||
chunkArray<T>(arr: T[], numChunks: number): T[][] {
|
||||
const chunkSize = Math.ceil(arr.length / numChunks)
|
||||
// Validate input to prevent division by zero or invalid chunks
|
||||
if (numChunks <= 0) {
|
||||
throw new Error(`Invalid numChunks: ${numChunks}. Must be a positive integer.`)
|
||||
}
|
||||
|
||||
if (arr.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const safeNumChunks = Math.max(1, Math.floor(numChunks))
|
||||
const chunkSize = Math.ceil(arr.length / safeNumChunks)
|
||||
const chunks: T[][] = []
|
||||
|
||||
for (let i = 0; i < arr.length; i += chunkSize) {
|
||||
@@ -39,4 +62,12 @@ export default class Util {
|
||||
return chunks
|
||||
}
|
||||
|
||||
stringToMs(input: string | number): number {
|
||||
const milisec = ms(input.toString())
|
||||
if (!milisec) {
|
||||
throw new Error('The string provided cannot be parsed to a valid time! Use a format like "1 min", "1m" or "1 minutes"')
|
||||
}
|
||||
return milisec
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { loadConfig } from './Load'
|
||||
|
||||
|
||||
export async function Webhook(content: string) {
|
||||
const webhook = loadConfig().webhook
|
||||
|
||||
if (!webhook.enabled || webhook.url.length < 10) return
|
||||
|
||||
const request = {
|
||||
method: 'POST',
|
||||
url: webhook.url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: {
|
||||
'content': content
|
||||
}
|
||||
}
|
||||
|
||||
await axios(request).catch(() => { })
|
||||
}
|
||||
@@ -39,7 +39,9 @@
|
||||
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
|
||||
/* Module Resolution Options */
|
||||
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
"moduleResolution":"node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||
"types": ["node"],
|
||||
// Keep explicit typeRoots to ensure resolution in environments that don't auto-detect before full install.
|
||||
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||
@@ -63,9 +65,9 @@
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/accounts.json",
|
||||
"src/config.json"
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/functions/queries.json"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
Reference in New Issue
Block a user