mirror of
https://github.com/TheNetsky/Microsoft-Rewards-Script.git
synced 2026-01-11 19:06:18 +00:00
Compare commits
100 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 |
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
|
||||
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,49 +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)
|
||||
|
||||
## Notes ##
|
||||
- If you end the script without closing the browser window first (only with headless as false), you'll be left with hanging chrome instances using resources. Use taskmanager to kill these or use the included `npm run chrome-kill-win` script. (Windows)
|
||||
- If you automate this script, set it to run at least 2 times a day to make sure it picked up all tasks, set `"runOnZeroPoints": false` so it doesn't run when no points are found.
|
||||
---
|
||||
|
||||
## Setup
|
||||
|
||||
## 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
|
||||
- [x] Solving This Or That Quiz (Random)
|
||||
- [x] Solving ABC Quiz
|
||||
- [ ] Completing Shopping Game
|
||||
- [ ] Completing Gaming Tab
|
||||
- [x] Clustering Support
|
||||
- [x] Proxy Support
|
||||
**Requirements:** Node.js ≥ 20 and Git
|
||||
Works on Windows, Linux, macOS, and WSL.
|
||||
|
||||
## 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!
|
||||
---
|
||||
|
||||
### 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
56
package.json
56
package.json
@@ -1,17 +1,32 @@
|
||||
{
|
||||
"name": "microsoft-rewards-script",
|
||||
"version": "1.3.1",
|
||||
"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",
|
||||
"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",
|
||||
"kill-chrome-win": "powershell -Command \"Get-Process | Where-Object { $_.MainModule.FileVersionInfo.FileDescription -eq 'Google Chrome for Testing' } | ForEach-Object { Stop-Process -Id $_.Id -Force }\""
|
||||
"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",
|
||||
@@ -19,23 +34,34 @@
|
||||
"Bot",
|
||||
"Script",
|
||||
"TypeScript",
|
||||
"Puppeteer",
|
||||
"Playwright",
|
||||
"Cheerio"
|
||||
],
|
||||
"author": "Netsky",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||
"eslint": "^8.54.0",
|
||||
"@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",
|
||||
"typescript": "^5.3.3"
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.5.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.2",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"fingerprint-generator": "^2.1.45",
|
||||
"fingerprint-injector": "^2.1.45",
|
||||
"puppeteer": "^21.6.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,19 +1,22 @@
|
||||
import puppeteer from 'puppeteer'
|
||||
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 { loadSesion } from '../util/Load'
|
||||
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
|
||||
|
||||
@@ -21,41 +24,117 @@ class Browser {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
async createBrowser(email: string, proxy: AccountProxy) {
|
||||
// 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 ? 'new' : false,
|
||||
userDataDir: await loadSesion(this.bot.config.sessionPath, email),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--mute-audio',
|
||||
'--disable-setuid-sandbox',
|
||||
'--ignore-certificate-errors',
|
||||
'--ignore-certificate-errors-spki-list',
|
||||
'--ignore-ssl-errors',
|
||||
proxy.url ? `--proxy-server=${proxy.url}:${proxy.port}` : ''
|
||||
]
|
||||
})
|
||||
|
||||
const { fingerprint, headers } = new FingerprintGenerator().getFingerprint({
|
||||
devices: this.bot.isMobile ? ['mobile'] : ['desktop'],
|
||||
operatingSystems: this.bot.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,10 +1,15 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import { BrowserContext, Page } from 'rebrowser-playwright'
|
||||
import { CheerioAPI, load } from 'cheerio'
|
||||
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 {
|
||||
@@ -17,105 +22,266 @@ export default class BrowserFunc {
|
||||
|
||||
/**
|
||||
* Navigate the provided page to rewards homepage
|
||||
* @param {Page} page Puppeteer page
|
||||
* @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)
|
||||
|
||||
if (page.url() === dashboardURL.href) {
|
||||
return
|
||||
if (new URL(page.url()).hostname !== dashboardURL.hostname) {
|
||||
await navigateHome()
|
||||
}
|
||||
|
||||
await page.goto(this.bot.config.baseURL)
|
||||
let success = false
|
||||
|
||||
const maxIterations = 5 // Maximum iterations set to 5
|
||||
|
||||
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: 2000 }).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 })
|
||||
this.bot.log('GO-HOME', 'Visited homepage successfully')
|
||||
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'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Below runs if the homepage was unable to be visited
|
||||
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)
|
||||
} else {
|
||||
this.bot.log('GO-HOME', 'Visited homepage successfully')
|
||||
break
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(5000)
|
||||
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) {
|
||||
throw this.bot.log('GO-HOME', 'An error occurred:' + error, 'error')
|
||||
throw this.bot.log(
|
||||
this.bot.isMobile,
|
||||
'GO-HOME',
|
||||
'An error occurred:' + (error instanceof Error ? ` ${error.message}` : ` ${String(error)}`),
|
||||
'error'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Fetch user dashboard data
|
||||
* @returns {DashboardData} Object of user bing rewards dashboard data
|
||||
*/
|
||||
async getDashboardData(): Promise<DashboardData> {
|
||||
async getDashboardData(page?: Page): Promise<DashboardData> {
|
||||
const target = page ?? this.bot.homePage
|
||||
const dashboardURL = new URL(this.bot.config.baseURL)
|
||||
const currentURL = new URL(this.bot.homePage.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(this.bot.homePage)
|
||||
try {
|
||||
// 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(this.bot.isMobile, 'GET-DASHBOARD-DATA', `Error fetching dashboard data: ${error}`, 'error')
|
||||
}
|
||||
|
||||
// Reload the page to get new data
|
||||
await this.bot.homePage.reload({ waitUntil: 'networkidle2' })
|
||||
|
||||
const scriptContent = await this.bot.homePage.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 this.bot.homePage.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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,38 +295,113 @@ export default class BrowserFunc {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total earnable points
|
||||
* Get total earnable points with web browser
|
||||
* @returns {number} Total earnable points
|
||||
*/
|
||||
async getEarnablePoints(): Promise<number> {
|
||||
async getBrowserEarnablePoints(): Promise<EarnablePoints> {
|
||||
try {
|
||||
let desktopSearchPoints = 0
|
||||
let mobileSearchPoints = 0
|
||||
let dailySetPoints = 0
|
||||
let morePromotionsPoints = 0
|
||||
|
||||
const data = await this.getDashboardData()
|
||||
|
||||
// These only include the points from tasks that the script can complete!
|
||||
let totalEarnablePoints = 0
|
||||
|
||||
// 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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total earnable points with mobile app
|
||||
* @returns {number} Total earnable points
|
||||
*/
|
||||
async getAppEarnablePoints(accessToken: string) {
|
||||
try {
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,60 +415,96 @@ export default class BrowserFunc {
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse quiz data from provided page
|
||||
* @param {Page} page Puppeteer page
|
||||
* @param {Page} page Playwright page
|
||||
* @returns {QuizData} Quiz data object
|
||||
*/
|
||||
async getQuizData(page: Page): Promise<QuizData> {
|
||||
try {
|
||||
// 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)
|
||||
|
||||
const scriptContent = $('script').filter((index, element) => {
|
||||
return $(element).text().includes('_w.rewardsQuizRenderInfo')
|
||||
}).text()
|
||||
// Try multiple possible variable names
|
||||
const possibleVariables = [
|
||||
'_w.rewardsQuizRenderInfo',
|
||||
'rewardsQuizRenderInfo',
|
||||
'_w.quizRenderInfo',
|
||||
'quizRenderInfo'
|
||||
]
|
||||
|
||||
if (scriptContent) {
|
||||
const regex = /_w\.rewardsQuizRenderInfo\s*=\s*({.*?});/s
|
||||
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('GET-QUIZ-DATA', 'Quiz data not found within script', 'error')
|
||||
throw this.bot.log(this.bot.isMobile, 'GET-QUIZ-DATA', `Variable ${foundVariable} found but could not extract JSON data`, 'error')
|
||||
}
|
||||
} else {
|
||||
throw this.bot.log('GET-QUIZ-DATA', 'Script containing quiz data not found', 'error')
|
||||
// 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')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
throw this.bot.log('GET-QUIZ-DATA', 'An error occurred:' + error, 'error')
|
||||
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('span.rqMCredits', { 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) {
|
||||
@@ -235,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)
|
||||
|
||||
@@ -248,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,124 +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 getTabs(page: Page) {
|
||||
async reloadBadPage(page: Page): Promise<void> {
|
||||
try {
|
||||
const browser = page.browser()
|
||||
const pages = await browser.pages()
|
||||
const html = await page.content().catch(() => '')
|
||||
const $ = load(html)
|
||||
|
||||
const homeTab = pages[1]
|
||||
let homeTabURL: URL
|
||||
const isNetworkError = $('body.neterror').length
|
||||
|
||||
if (!homeTab) {
|
||||
throw this.bot.log('GET-TABS', 'Home tab could not be found!', 'error')
|
||||
|
||||
} else {
|
||||
homeTabURL = new URL(homeTab.url())
|
||||
|
||||
if (homeTabURL.hostname !== 'rewards.bing.com') {
|
||||
throw this.bot.log('GET-TABS', 'Reward page hostname is invalid: ' + homeTabURL.host, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const workerTab = pages[2]
|
||||
if (!workerTab) {
|
||||
throw this.bot.log('GET-TABS', 'Worker tab could not be found!', 'error')
|
||||
}
|
||||
|
||||
return {
|
||||
homeTab: homeTab,
|
||||
workerTab: workerTab
|
||||
if (isNetworkError) {
|
||||
this.bot.log(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'Bad page detected, reloading!')
|
||||
await page.reload()
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
throw this.bot.log('GET-TABS', 'An error occurred:' + error, '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,17 +8,114 @@ 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -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,136 +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://rewards.bing.com/signin')
|
||||
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="RewardsPortal"]', { timeout: 10_000 }).then(() => true).catch(() => false)
|
||||
const code = await inputPromise
|
||||
|
||||
if (!isLoggedIn) {
|
||||
// Check if account is locked
|
||||
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)
|
||||
}
|
||||
|
||||
// When erroring at this stage it means a 2FA code is required
|
||||
} catch (error) {
|
||||
this.bot.log('LOGIN', '2FA code required')
|
||||
|
||||
// Wait for user input
|
||||
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 !== 'rewards.bing.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="RewardsPortal"]', { 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.browser.utils.tryDismissBingCookieBanner(page)
|
||||
|
||||
const loggedIn = await this.checkBingLoginStatus(page)
|
||||
// If mobile browser, skip this step
|
||||
if (loggedIn || this.bot.isMobile) {
|
||||
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: 5000 })
|
||||
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,70 +1,106 @@
|
||||
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)
|
||||
|
||||
// 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('DAILY-SET', 'All "Daily Set" items have been completed')
|
||||
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}"`)
|
||||
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.waitForNetworkIdle({ timeout: 5_000 }).catch(() => { })
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => { })
|
||||
|
||||
await this.solveActivities(page, activitiesUncompleted, punchCard)
|
||||
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
const pages = await (page.browser()).pages()
|
||||
const pages = page.context().pages()
|
||||
|
||||
if (pages.length > 3) {
|
||||
await page.close()
|
||||
@@ -72,10 +108,10 @@ export class Workers {
|
||||
await this.bot.browser.func.goHome(page)
|
||||
}
|
||||
|
||||
this.bot.log('PUNCH-CARD', `All items for punchcard: "${punchCard.parentPromotion.title}" have been completed`)
|
||||
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
|
||||
@@ -87,15 +123,15 @@ 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)
|
||||
|
||||
@@ -106,106 +142,101 @@ export class Workers {
|
||||
// Always return to the homepage if not already
|
||||
await this.bot.browser.func.goHome(page)
|
||||
|
||||
this.bot.log('MORE-PROMOTIONS', 'All "More Promotion" items have been completed')
|
||||
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(activityPage: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) {
|
||||
const activityInitial = activityPage.url() // Homepage for Daily/More and Index for promotions
|
||||
const activityInitial = activityPage.url()
|
||||
const retry = new Retry(this.bot.config.retryPolicy)
|
||||
const throttle = new AdaptiveThrottler()
|
||||
|
||||
for (const activity of activities) {
|
||||
try {
|
||||
// Reselect the worker page
|
||||
activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
|
||||
activityPage = await this.manageTabLifecycle(activityPage, activityInitial)
|
||||
await this.applyThrottle(throttle, 800, 1400)
|
||||
|
||||
const pages = await activityPage.browser().pages()
|
||||
if (pages.length > 3) {
|
||||
await activityPage.close()
|
||||
const selector = await this.buildActivitySelector(activityPage, activity, punchCard)
|
||||
await this.prepareActivityPage(activityPage, selector, throttle)
|
||||
|
||||
activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
|
||||
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')
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
|
||||
if (activityPage.url() !== activityInitial) {
|
||||
await activityPage.goto(activityInitial)
|
||||
}
|
||||
|
||||
|
||||
let selector = `[data-bi-id="${activity.offerId}"]`
|
||||
|
||||
if (punchCard) {
|
||||
selector = await this.bot.browser.func.getPunchCardActivity(activityPage, activity)
|
||||
|
||||
} else if (activity.name.toLowerCase().includes('membercenter')) {
|
||||
selector = `[data-bi-id="${activity.name}"]`
|
||||
}
|
||||
|
||||
// Click element, it will be opened in a new tab
|
||||
await activityPage.click(selector)
|
||||
|
||||
// Select the new activity page
|
||||
activityPage = await this.bot.browser.utils.getLatestTab(activityPage)
|
||||
|
||||
// Wait for the new tab to fully load, ignore error.
|
||||
/*
|
||||
Due to common false timeout on this function, we're ignoring the error regardless, if it worked then it's faster,
|
||||
if it didn't then it gave enough time for the page to load.
|
||||
*/
|
||||
await activityPage.waitForNetworkIdle({ timeout: 10_000 }).catch(() => { })
|
||||
await this.bot.utils.wait(5000)
|
||||
|
||||
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 }).catch(() => { }) // We're gonna click regardless or not
|
||||
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: 2000 }).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,7 +69,7 @@ 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
|
||||
}
|
||||
}
|
||||
@@ -56,35 +78,53 @@ export class Quiz extends Workers {
|
||||
} 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,19 +1,27 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import axios from 'axios'
|
||||
import { Page } from 'rebrowser-playwright'
|
||||
import { platform } from 'os'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
import { Counters, DashboardData } from '../../interface/DashboardData'
|
||||
import { GoogleTrends } from '../../interface/GoogleDailyTrends'
|
||||
import { GoogleSearch } from '../../interface/Search'
|
||||
import { AxiosRequestConfig } from 'axios'
|
||||
|
||||
type GoogleTrendsResponse = [
|
||||
string,
|
||||
[
|
||||
string,
|
||||
...null[],
|
||||
[string, ...string[]]
|
||||
][]
|
||||
];
|
||||
|
||||
export class Search extends Workers {
|
||||
|
||||
private searchPageURL = 'https://bing.com'
|
||||
private bingHome = 'https://bing.com'
|
||||
private searchPageURL = ''
|
||||
|
||||
public async doSearch(page: Page, data: DashboardData) {
|
||||
this.bot.log('SEARCH-BING', 'Starting bing searches')
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', 'Starting Bing searches')
|
||||
|
||||
page = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
@@ -21,21 +29,45 @@ export class Search extends Workers {
|
||||
let missingPoints = this.calculatePoints(searchCounters)
|
||||
|
||||
if (missingPoints === 0) {
|
||||
this.bot.log('SEARCH-BING', `Bing searches for ${this.bot.isMobile ? 'MOBILE' : 'DESKTOP'} have already been completed`)
|
||||
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)
|
||||
googleSearchQueries = this.bot.utils.shuffleArray(googleSearchQueries)
|
||||
// Generate search queries (primary: Google Trends)
|
||||
const geo = this.bot.config.searchSettings.useGeoLocaleQueries ? data.userProfile.attributes.country : 'US'
|
||||
let googleSearchQueries = await this.getGoogleTrends(geo)
|
||||
|
||||
// Deduplicate the search terms
|
||||
googleSearchQueries = [...new Set(googleSearchQueries)]
|
||||
// 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 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 page.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[] = []
|
||||
// Mobile search doesn't seem to like related queries?
|
||||
@@ -45,55 +77,67 @@ export class Search extends Workers {
|
||||
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: ${this.bot.isMobile}`)
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING', `${missingPoints} Points Remaining | Query: ${query}`)
|
||||
|
||||
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: ${this.bot.isMobile}`)
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-BING-EXTRA', `${missingPoints} Points Remaining | Query: ${term}`)
|
||||
|
||||
searchCounters = await this.bingSearch(page, query.topic)
|
||||
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
|
||||
@@ -104,122 +148,172 @@ export class Search extends Workers {
|
||||
}
|
||||
|
||||
// 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(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()
|
||||
|
||||
} 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 browser = searchPage.browser()
|
||||
const tabs = await browser.pages()
|
||||
const lastTab = await this.bot.browser.utils.getLatestTab(searchPage)
|
||||
|
||||
if (tabs.length === 4) {
|
||||
await lastTab.close()
|
||||
|
||||
} else if (tabs.length === 2) {
|
||||
const newPage = await browser.newPage()
|
||||
await newPage.goto(this.searchPageURL)
|
||||
|
||||
} else {
|
||||
await lastTab.goBack()
|
||||
}
|
||||
await this.closeTabs(lastTab)
|
||||
|
||||
await this.bot.utils.wait(4000)
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.log('SEARCH-BING', 'Search failed after 5 retries, ending', 'error')
|
||||
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.toLowerCase())
|
||||
})
|
||||
}
|
||||
|
||||
} 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 = {
|
||||
@@ -230,65 +324,51 @@ 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, 400); 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)
|
||||
|
||||
// Will get current tab if no new one is created
|
||||
// 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, 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)
|
||||
|
||||
// Let website load, if it doesn't load within 5 sec. exit regardless
|
||||
await this.bot.utils.wait(5000)
|
||||
|
||||
let lastTabURL = new URL(lastTab.url()) // Get new tab info
|
||||
let lastTabURL = new URL(lastTab.url()) // Get new tab info, this is the website we're visiting
|
||||
|
||||
// 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) {
|
||||
const browser = page.browser()
|
||||
const tabs = await browser.pages()
|
||||
while (lastTabURL.href !== this.searchPageURL && i < 5) {
|
||||
|
||||
if (tabs.length === 4) {
|
||||
await lastTab.close()
|
||||
|
||||
} else if (tabs.length === 2) {
|
||||
const newPage = await browser.newPage()
|
||||
await newPage.goto(searchListingURL.href)
|
||||
|
||||
} else {
|
||||
await lastTab.goBack()
|
||||
}
|
||||
await this.closeTabs(lastTab)
|
||||
|
||||
// End of loop, refresh lastPage
|
||||
lastTab = await this.bot.browser.utils.getLatestTab(page) // Finally update the lastTab var again
|
||||
@@ -297,10 +377,45 @@ export class Search extends Workers {
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log('SEARCH-RANDOM-CLICK', 'An error occurred:' + error, 'error')
|
||||
this.bot.log(this.bot.isMobile, 'SEARCH-RANDOM-CLICK', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
private async closeTabs(lastTab: Page) {
|
||||
const browser = lastTab.context()
|
||||
const tabs = browser.pages()
|
||||
|
||||
try {
|
||||
if (tabs.length > 2) {
|
||||
// If more than 2 tabs are open, close the last tab
|
||||
|
||||
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 (tabs.length === 1) {
|
||||
// If only 1 tab is open, open a new one to search in
|
||||
|
||||
const newPage = await browser.newPage()
|
||||
await this.bot.utils.wait(1000)
|
||||
|
||||
await newPage.goto(this.bingHome)
|
||||
await this.bot.utils.wait(3000)
|
||||
this.searchPageURL = newPage.url()
|
||||
|
||||
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
|
||||
@@ -313,4 +428,18 @@ export class Search extends Workers {
|
||||
|
||||
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) {
|
||||
// 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,24 +1,25 @@
|
||||
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')
|
||||
|
||||
|
||||
try {
|
||||
// Check if the quiz has been started or not
|
||||
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { visible: true, timeout: 2000 }).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)
|
||||
@@ -32,15 +33,15 @@ export class ThisOrThat 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
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.log('THIS-OR-THAT', 'Completed the ThisOrThat successfully')
|
||||
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,17 +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 {
|
||||
this.bot.utils.wait(2000)
|
||||
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é"
|
||||
]
|
||||
}
|
||||
]
|
||||
807
src/index.ts
807
src/index.ts
@@ -1,5 +1,7 @@
|
||||
import cluster from 'cluster'
|
||||
import { Page } from 'puppeteer'
|
||||
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'
|
||||
@@ -7,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 {
|
||||
@@ -25,36 +34,63 @@ export class MicrosoftRewardsBot {
|
||||
func: BrowserFunc,
|
||||
utils: BrowserUtil
|
||||
}
|
||||
public isMobile: boolean = false
|
||||
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) {
|
||||
@@ -64,99 +100,424 @@ 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)
|
||||
log(false,'FLOW','Desktop() invoked')
|
||||
const browser = await this.browserFactory.createBrowser(account.proxy, account.email)
|
||||
this.homePage = await browser.newPage()
|
||||
let pages = await browser.pages()
|
||||
|
||||
// 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 this.homePage.authenticate({ username: account.proxy.username, password: account.proxy.password })
|
||||
|
||||
log('MAIN', 'Starting DESKTOP browser')
|
||||
|
||||
// Login into MS Rewards, then go to rewards homepage
|
||||
await this.login.login(this.homePage, account.email, account.password)
|
||||
await this.browser.func.goHome(this.homePage)
|
||||
|
||||
const data = await this.browser.func.getDashboardData()
|
||||
log('MAIN-POINTS', `Current point count: ${data.userStatus.availablePoints}`)
|
||||
|
||||
const earnablePoints = await this.browser.func.getEarnablePoints()
|
||||
this.collectedPoints = earnablePoints
|
||||
log('MAIN-POINTS', `You can earn ${earnablePoints} points today`)
|
||||
this.pointsInitial = data.userStatus.availablePoints
|
||||
const initial = this.pointsInitial
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -185,82 +546,332 @@ export class MicrosoftRewardsBot {
|
||||
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) {
|
||||
this.isMobile = true
|
||||
|
||||
const browser = await this.browserFactory.createBrowser(account.email, account.proxy)
|
||||
log(true,'FLOW','Mobile() invoked')
|
||||
const browser = await this.browserFactory.createBrowser(account.proxy, account.email)
|
||||
this.homePage = await browser.newPage()
|
||||
let pages = await browser.pages()
|
||||
|
||||
// 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 this.homePage.authenticate({ username: account.proxy.username, password: account.proxy.password })
|
||||
this.accessToken = await this.login.getMobileAccessToken(this.homePage, account.email)
|
||||
|
||||
log('MAIN', 'Starting MOBILE browser')
|
||||
|
||||
// Login into MS Rewards, then go to rewards homepage
|
||||
await this.login.login(this.homePage, account.email, account.password)
|
||||
await this.browser.func.goHome(this.homePage)
|
||||
|
||||
const data = await this.browser.func.getDashboardData()
|
||||
const data = await this.browser.func.getDashboardData()
|
||||
const initialPoints = data.userStatus.availablePoints || this.pointsInitial || 0
|
||||
|
||||
// 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!')
|
||||
const browserEnarablePoints = await this.browser.func.getBrowserEarnablePoints()
|
||||
const appEarnablePoints = await this.browser.func.getAppEarnablePoints(this.accessToken)
|
||||
|
||||
// Close mobile browser
|
||||
return await browser.close()
|
||||
this.pointsCanCollect = browserEnarablePoints.mobileSearchPoints + appEarnablePoints.totalEarnablePoints
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
// Open a new tab to where the tasks are going to be completed
|
||||
const workerPage = await browser.newPage()
|
||||
// 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')
|
||||
|
||||
// Go to homepage on worker page
|
||||
await this.browser.func.goHome(workerPage)
|
||||
// Close mobile browser
|
||||
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(workerPage, data)
|
||||
// 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()
|
||||
|
||||
// Fetch current search points
|
||||
const mobileSearchPoints = (await this.browser.func.getSearchPoints()).mobileSearch?.[0]
|
||||
// Go to homepage on worker page
|
||||
await this.browser.func.goHome(workerPage)
|
||||
|
||||
// If the remaining mobile points does not equal 0, restart and assume the generated UA is invalid
|
||||
if (mobileSearchPoints && ((mobileSearchPoints.pointProgressMax - mobileSearchPoints.pointProgress) > 0)) {
|
||||
log('MAIN', 'Unable to complete mobile searches, bad User-Agent?, retrying...')
|
||||
await this.activities.doSearch(workerPage, data)
|
||||
|
||||
// Close mobile browser
|
||||
await browser.close()
|
||||
// Fetch current search points
|
||||
const mobileSearchPoints = (await this.browser.func.getSearchPoints()).mobileSearch?.[0]
|
||||
|
||||
// Retry
|
||||
await this.Mobile(account)
|
||||
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()
|
||||
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
|
||||
280
src/util/Load.ts
280
src/util/Load.ts
@@ -1,40 +1,289 @@
|
||||
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 loadSesion(sessionPath: string, email: string): Promise<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)
|
||||
@@ -44,8 +293,15 @@ export async function loadSesion(sessionPath: string, email: string): Promise<st
|
||||
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'
|
||||
|
||||
import { Ntfy } from './Ntfy'
|
||||
import { loadConfig } from './Load'
|
||||
import { DISCORD } from '../constants'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function log(title: string, message: string, type?: 'log' | 'warn' | 'error') {
|
||||
const currentTime = new Date().toLocaleString()
|
||||
const platformText = isMobile === 'main' ? 'MAIN' : isMobile ? 'MOBILE' : 'DESKTOP'
|
||||
|
||||
let str = ''
|
||||
// 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