mirror of
https://github.com/TheNetsky/Microsoft-Rewards-Script.git
synced 2026-01-11 19:06:18 +00:00
Compare commits
106 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f9c88f8d8 | ||
|
|
032debed62 | ||
|
|
ca3253ac52 | ||
|
|
171521c51f | ||
|
|
8365a0c422 | ||
|
|
62eb1775e3 | ||
|
|
576899f39d | ||
|
|
a8ddb65b21 | ||
|
|
0e8ca0c862 | ||
|
|
ffb4a28785 | ||
|
|
efbadb7d0b | ||
|
|
f9fcbd851e | ||
|
|
0b419fad38 | ||
|
|
bd3fa2c603 | ||
|
|
edd69c3bd7 | ||
|
|
5176cfb02d | ||
|
|
2c4d85f732 | ||
|
|
7b4b20ab4e | ||
|
|
0059192519 | ||
|
|
a36b7eaef5 | ||
|
|
59b9f25d2c | ||
|
|
e89b8c0c52 | ||
|
|
649cf28ad9 | ||
|
|
624f5a32e1 | ||
|
|
3b21f45fcc | ||
|
|
f857b3ad1a | ||
|
|
0b7088a7e6 | ||
|
|
64ffabcceb | ||
|
|
86f65b448d | ||
|
|
14492b27cb | ||
|
|
62d1a0a14b | ||
|
|
02160a07d9 | ||
|
|
b66114d4dd | ||
|
|
2e80266ad1 | ||
|
|
072e96dd53 | ||
|
|
eea4407454 | ||
|
|
ad2e0b8bb3 | ||
|
|
ad3b215dbc | ||
|
|
f51daf06d6 | ||
|
|
e7c27ac16e | ||
|
|
9f5601d44b | ||
|
|
d5fd06d229 | ||
|
|
0ddc964878 | ||
|
|
325bf65b30 | ||
|
|
6f19bd4b0e | ||
|
|
caf6a42a38 | ||
|
|
352d47229b | ||
|
|
9a12ee1ec8 | ||
|
|
b630c3ddda | ||
|
|
287e3897da | ||
|
|
fcf6aba446 | ||
|
|
1102f2ca94 | ||
|
|
82a896e83f | ||
|
|
b0bd1f52c4 | ||
|
|
7e4121e01b | ||
|
|
849406c44f | ||
|
|
d1f4364e18 | ||
|
|
cf79467a4e | ||
|
|
ae554a0639 | ||
|
|
1a6f9f4ac3 | ||
|
|
45083ed41f | ||
|
|
8b489d50f7 | ||
|
|
7151f2351a | ||
|
|
5afd8cbe1d | ||
|
|
f015649d16 | ||
|
|
034359019c | ||
|
|
09ddbee45a | ||
|
|
7939196e88 | ||
|
|
5bc66c5fc9 | ||
|
|
c70d6f9cb1 | ||
|
|
a47b86e74d | ||
|
|
ce2a72ee36 | ||
|
|
755237caa1 | ||
|
|
2b4cd505c0 | ||
|
|
a39a861dab | ||
|
|
8d19129906 | ||
|
|
c6ab80fe54 | ||
|
|
9b1eed526f | ||
|
|
9a144b2e60 | ||
|
|
28b1881642 | ||
|
|
ef6ad569ff | ||
|
|
da9ba91c5c | ||
|
|
deb2d58b1b | ||
|
|
66a82c2584 | ||
|
|
8a022d5983 | ||
|
|
64048e35d7 | ||
|
|
cf7f7ac790 | ||
|
|
f7aa5039f9 | ||
|
|
e082fb03f0 | ||
|
|
0303b8c605 | ||
|
|
2fea17c415 | ||
|
|
c5beccb54b | ||
|
|
b566ccaece | ||
|
|
15b2b827eb | ||
|
|
02518ee4ba | ||
|
|
69819b5631 | ||
|
|
b389b87792 | ||
|
|
9ea7f5c452 | ||
|
|
f3fb641ecd | ||
|
|
bca1e7c896 | ||
|
|
fd7c8e36d4 | ||
|
|
dcb0c25d46 | ||
|
|
a8cb5482d4 | ||
|
|
28286ff9fe | ||
|
|
1d6167aeca | ||
|
|
3b15fe19a7 |
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.vscode
|
||||
.DS_Store
|
||||
sessions/
|
||||
.dev/
|
||||
diagnostics/
|
||||
note
|
||||
accounts.dev.json
|
||||
accounts.main.json
|
||||
.playwright-chromium-installed
|
||||
50
.eslintrc.js
50
.eslintrc.js
@@ -1,36 +1,28 @@
|
||||
module.exports = {
|
||||
'env': {
|
||||
'es2021': true,
|
||||
'node': true
|
||||
env: {
|
||||
es2021: true,
|
||||
node: true
|
||||
},
|
||||
'extends': [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended'
|
||||
],
|
||||
'parser': '@typescript-eslint/parser',
|
||||
'parserOptions': {
|
||||
'ecmaVersion': 12,
|
||||
'sourceType': 'module'
|
||||
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 12,
|
||||
sourceType: 'module'
|
||||
},
|
||||
'plugins': [
|
||||
'@typescript-eslint'
|
||||
],
|
||||
'rules': {
|
||||
'linebreak-style': [
|
||||
'error',
|
||||
'unix'
|
||||
],
|
||||
'quotes': [
|
||||
'error',
|
||||
'single'
|
||||
],
|
||||
'semi': [
|
||||
'error',
|
||||
'never'
|
||||
plugins: ['@typescript-eslint'],
|
||||
rules: {
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: ['error', 'single'],
|
||||
semi: ['error', 'never'],
|
||||
'@typescript-eslint/no-explicit-any': [
|
||||
'warn',
|
||||
{
|
||||
fixToUnknown: false
|
||||
}
|
||||
],
|
||||
'comma-dangle': 'off',
|
||||
'@typescript-eslint/comma-dangle': 'error',
|
||||
'prefer-arrow-callback': 'error'
|
||||
// Add any other rules you want to enforce here
|
||||
'prefer-arrow-callback': 'error',
|
||||
'no-empty': 'off'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
*.sh text eol=lf
|
||||
*.template text eol=lf
|
||||
71
.github/workflows/auto-release.yml
vendored
Normal file
71
.github/workflows/auto-release.yml
vendored
Normal file
@@ -0,0 +1,71 @@
|
||||
name: Auto Release on package.json version bump
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [v3]
|
||||
paths:
|
||||
- package.json
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Read current version
|
||||
id: current
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Read previous version (from previous commit)
|
||||
id: previous
|
||||
run: |
|
||||
PREV_VERSION=$(git show HEAD~1:package.json 2>/dev/null | node -p "JSON.parse(require('fs').readFileSync(0,'utf8')).version" || echo "")
|
||||
echo "version=$PREV_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Check if version increased
|
||||
id: check
|
||||
run: |
|
||||
CUR="${{ steps.current.outputs.version }}"
|
||||
PREV="${{ steps.previous.outputs.version }}"
|
||||
|
||||
echo "Current: $CUR"
|
||||
echo "Previous: $PREV"
|
||||
|
||||
# if previous doesn't exist (first commit containing package.json), skip
|
||||
if [ -z "$PREV" ]; then
|
||||
echo "should_release=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Compare semver using node's semver package if available; fallback to simple inequality
|
||||
node -e "
|
||||
const cur='${CUR}';
|
||||
const prev='${PREV}';
|
||||
let should = cur !== prev;
|
||||
try {
|
||||
const semver = require('semver');
|
||||
should = semver.gt(cur, prev);
|
||||
} catch (_) {}
|
||||
console.log('should_release=' + should);
|
||||
" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Stop if no bump
|
||||
if: steps.check.outputs.should_release != 'true'
|
||||
run: echo "No version increase detected."
|
||||
|
||||
- name: Create tag + GitHub Release
|
||||
if: steps.check.outputs.should_release == 'true'
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ steps.current.outputs.version }}
|
||||
name: v${{ steps.current.outputs.version }}
|
||||
generate_release_notes: true
|
||||
51
.github/workflows/docker-release.yml
vendored
Normal file
51
.github/workflows/docker-release.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Build and Push Docker Image on Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,8 +1,14 @@
|
||||
sessions/
|
||||
dist/
|
||||
.dev/
|
||||
node_modules/
|
||||
package-lock.json
|
||||
accounts.json
|
||||
notes
|
||||
src/accounts.json
|
||||
src/config.json
|
||||
/.vscode
|
||||
/diagnostics
|
||||
note
|
||||
accounts.dev.json
|
||||
accounts.main.json
|
||||
accounts.main.json
|
||||
.DS_Store
|
||||
.playwright-chromium-installed
|
||||
bun.lock
|
||||
|
||||
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"tabWidth": 4,
|
||||
"useTabs": false,
|
||||
"printWidth": 120,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
20
.vscode/launch.json
vendored
20
.vscode/launch.json
vendored
@@ -1,20 +0,0 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"name": "Launch Program",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"program": "${file}",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/**/*.js"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
89
Dockerfile
Normal file
89
Dockerfile
Normal file
@@ -0,0 +1,89 @@
|
||||
###############################################################################
|
||||
# Stage 1: Builder
|
||||
###############################################################################
|
||||
FROM node:24-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 patchright install --with-deps --only-shell chromium \
|
||||
&& rm -rf /root/.cache /tmp/* /var/tmp/*
|
||||
|
||||
###############################################################################
|
||||
# Stage 2: Runtime
|
||||
###############################################################################
|
||||
FROM node:24-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 from the start
|
||||
COPY --chmod=755 scripts/docker/run_daily.sh ./scripts/docker/run_daily.sh
|
||||
COPY --chmod=644 src/crontab.template /etc/cron.d/microsoft-rewards-cron.template
|
||||
COPY --chmod=755 scripts/docker/entrypoint.sh /usr/local/bin/entrypoint.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>.
|
||||
51
README.md
51
README.md
@@ -1,45 +1,14 @@
|
||||
# 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
|
||||
TODO
|
||||
|
||||
## Features ##
|
||||
- [x] Multi-Account Support
|
||||
- [x] Session Storing
|
||||
- [x] 2FA Support
|
||||
- [x] Headless Support
|
||||
- [x] Discord Webhook Support
|
||||
- [x] Desktop Searches
|
||||
- [x] Configurable Tasks
|
||||
- [x] Microsoft Edge Searches
|
||||
- [x] Mobile Searches
|
||||
- [x] Emulated Scrolling Support
|
||||
- [x] Emulated Link Clicking Support
|
||||
- [x] Geo Locale Search Queries
|
||||
- [x] Completing Daily Set
|
||||
- [x] Completing More Promotions
|
||||
- [x] Solving Quiz (10 point variant)
|
||||
- [x] Solving Quiz (30-40 point variant)
|
||||
- [x] Completing Click Rewards
|
||||
- [x] Completing Polls
|
||||
- [x] Completing Punchcards
|
||||
- [ ] Solving This Or That Quiz
|
||||
- [x] Clicking Promotional Items
|
||||
- [x] Solving ABC Quiz
|
||||
- [ ] Completing Shopping Game
|
||||
- [ ] Completing Gaming Tab
|
||||
- [x] Clustering Support
|
||||
- [x] Proxy Support
|
||||
[For installation see the main (v1) or v2 branch (mostly the same)](https://github.com/TheNetsky/Microsoft-Rewards-Script/tree/main?tab=readme-ov-file#setup)
|
||||
|
||||
## 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!
|
||||
## 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.
|
||||
|
||||
37
compose.yaml
Normal file
37
compose.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
services:
|
||||
microsoft-rewards-script:
|
||||
build: .
|
||||
container_name: microsoft-rewards-script
|
||||
restart: unless-stopped
|
||||
|
||||
# Create and customize your accounts.json and config.json prior to deploying the container (default location: /src/)
|
||||
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
|
||||
|
||||
environment:
|
||||
TZ: 'America/Toronto' # Set your timezone for proper scheduling
|
||||
NODE_ENV: 'production'
|
||||
CRON_SCHEDULE: '0 7 * * *' # Customize your schedule, use crontab.guru for formatting
|
||||
RUN_ON_START: 'true' # Runs the script immediately on container startup
|
||||
|
||||
# Add a small random delay to the scheduled start time (uncomment to customize delay, or disable)
|
||||
#MIN_SLEEP_MINUTES: "5"
|
||||
#MAX_SLEEP_MINUTES: "50"
|
||||
SKIP_RANDOM_SLEEP: 'false'
|
||||
|
||||
# Set a timeout for stuck script runs (default: 8h, uncomment to customize)
|
||||
#STUCK_PROCESS_TIMEOUT_HOURS: "8"
|
||||
|
||||
# Health check: ensures cron is running, container marked unhealthy if cron stops
|
||||
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
|
||||
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
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
3011
package-lock.json
generated
Normal file
3011
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
101
package.json
101
package.json
@@ -1,41 +1,64 @@
|
||||
{
|
||||
"name": "microsoft-rewards-script",
|
||||
"version": "1.2.4",
|
||||
"description": "Automatically do tasks for Microsoft Rewards but in TS!",
|
||||
"main": "index.js",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node ./dist/index.js",
|
||||
"ts-start": "ts-node ./src/index.ts",
|
||||
"dev": "ts-node ./src/index.ts -dev"
|
||||
},
|
||||
"keywords": [
|
||||
"Bing Rewards",
|
||||
"Microsoft Rewards",
|
||||
"Bot",
|
||||
"Script",
|
||||
"TypeScript",
|
||||
"Puppeteer",
|
||||
"Cheerio"
|
||||
],
|
||||
"author": "Netsky",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^6.7.0",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.5.1",
|
||||
"cheerio": "^1.0.0-rc.12",
|
||||
"eslint": "^8.49.0",
|
||||
"eslint-plugin-modules-newline": "^0.0.6",
|
||||
"fingerprint-generator": "^2.1.42",
|
||||
"fingerprint-injector": "^2.1.42",
|
||||
"puppeteer": "^21.4.1",
|
||||
"puppeteer-extra": "^3.3.6",
|
||||
"ts-node": "^10.9.1"
|
||||
}
|
||||
"name": "microsoft-rewards-script",
|
||||
"version": "3.0.2",
|
||||
"description": "Automatically do tasks for Microsoft Rewards but in TS!",
|
||||
"author": "Netsky",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=24.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"pre-build": "npm i && rimraf dist && npx patchright install chromium",
|
||||
"build": "rimraf dist && tsc",
|
||||
"start": "node ./dist/index.js",
|
||||
"ts-start": "ts-node ./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 }\"",
|
||||
"create-docker": "docker build -t microsoft-rewards-script-docker .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"clear-diagnostics": "rimraf diagnostics",
|
||||
"clear-sessions": "node ./scripts/main/clearSessions.js",
|
||||
"open-session": "node ./scripts/main/browserSession.js -email",
|
||||
"open-session:dev": "node ./scripts/main/browserSession.js -dev -email ItsNetsky@protonmail.com"
|
||||
},
|
||||
"keywords": [
|
||||
"Bing Rewards",
|
||||
"Microsoft Rewards",
|
||||
"Bot",
|
||||
"Script",
|
||||
"TypeScript",
|
||||
"Playwright",
|
||||
"Cheerio"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-modules-newline": "^0.0.6",
|
||||
"prettier": "^3.7.1",
|
||||
"rimraf": "^6.1.2",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
"axios-retry": "^4.5.0",
|
||||
"chalk": "^4.1.2",
|
||||
"cheerio": "^1.0.0",
|
||||
"fingerprint-generator": "^2.1.79",
|
||||
"fingerprint-injector": "^2.1.79",
|
||||
"ghost-cursor-playwright-port": "^1.4.3",
|
||||
"http-proxy-agent": "^7.0.2",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
"ms": "^2.1.3",
|
||||
"otpauth": "^9.4.1",
|
||||
"p-queue": "^9.0.1",
|
||||
"patchright": "^1.57.0",
|
||||
"semver": "^7.7.3",
|
||||
"socks-proxy-agent": "^8.0.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"zod": "^4.3.5"
|
||||
}
|
||||
}
|
||||
|
||||
50
scripts/docker/entrypoint.sh
Normal file
50
scripts/docker/entrypoint.sh
Normal 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 scripts/docker/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
|
||||
155
scripts/docker/run_daily.sh
Normal file
155
scripts/docker/run_daily.sh
Normal file
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
168
scripts/main/browserSession.js
Normal file
168
scripts/main/browserSession.js
Normal file
@@ -0,0 +1,168 @@
|
||||
import fs from 'fs'
|
||||
import { chromium } from 'patchright'
|
||||
import { newInjectedContext } from 'fingerprint-injector'
|
||||
import {
|
||||
getDirname,
|
||||
getProjectRoot,
|
||||
log,
|
||||
parseArgs,
|
||||
validateEmail,
|
||||
loadConfig,
|
||||
loadAccounts,
|
||||
findAccountByEmail,
|
||||
getRuntimeBase,
|
||||
getSessionPath,
|
||||
loadCookies,
|
||||
loadFingerprint,
|
||||
buildProxyConfig,
|
||||
setupCleanupHandlers
|
||||
} from '../utils.js'
|
||||
|
||||
const __dirname = getDirname(import.meta.url)
|
||||
const projectRoot = getProjectRoot(__dirname)
|
||||
|
||||
const args = parseArgs()
|
||||
args.dev = args.dev || false
|
||||
|
||||
validateEmail(args.email)
|
||||
|
||||
const { data: config } = loadConfig(projectRoot, args.dev)
|
||||
const { data: accounts } = loadAccounts(projectRoot, args.dev)
|
||||
|
||||
const account = findAccountByEmail(accounts, args.email)
|
||||
if (!account) {
|
||||
log('ERROR', `Account not found: ${args.email}`)
|
||||
log('ERROR', 'Available accounts:')
|
||||
accounts.forEach(acc => {
|
||||
if (acc?.email) log('ERROR', ` - ${acc.email}`)
|
||||
})
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const runtimeBase = getRuntimeBase(projectRoot, args.dev)
|
||||
const sessionBase = getSessionPath(runtimeBase, config.sessionPath, args.email)
|
||||
|
||||
log('INFO', 'Validating session data...')
|
||||
|
||||
if (!fs.existsSync(sessionBase)) {
|
||||
log('ERROR', `Session directory does not exist: ${sessionBase}`)
|
||||
log('ERROR', 'Please ensure the session has been created for this account')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!config.baseURL) {
|
||||
log('ERROR', 'baseURL is not set in config.json')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
let cookies = await loadCookies(sessionBase, 'desktop')
|
||||
let sessionType = 'desktop'
|
||||
|
||||
if (cookies.length === 0) {
|
||||
log('WARN', 'No desktop session cookies found, trying mobile session...')
|
||||
cookies = await loadCookies(sessionBase, 'mobile')
|
||||
sessionType = 'mobile'
|
||||
|
||||
if (cookies.length === 0) {
|
||||
log('ERROR', 'No cookies found in desktop or mobile session')
|
||||
log('ERROR', `Session directory: ${sessionBase}`)
|
||||
log('ERROR', 'Please ensure a valid session exists for this account')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
log('INFO', `Using mobile session (${cookies.length} cookies)`)
|
||||
}
|
||||
|
||||
const isMobile = sessionType === 'mobile'
|
||||
const fingerprintEnabled = isMobile ? account.saveFingerprint?.mobile : account.saveFingerprint?.desktop
|
||||
|
||||
let fingerprint = null
|
||||
if (fingerprintEnabled) {
|
||||
fingerprint = await loadFingerprint(sessionBase, sessionType)
|
||||
if (!fingerprint) {
|
||||
log('ERROR', `Fingerprint is enabled for ${sessionType} but fingerprint file not found`)
|
||||
log('ERROR', `Expected file: ${sessionBase}/session_fingerprint_${sessionType}.json`)
|
||||
log('ERROR', 'Cannot start browser without fingerprint when it is explicitly enabled')
|
||||
process.exit(1)
|
||||
}
|
||||
log('INFO', `Loaded ${sessionType} fingerprint`)
|
||||
}
|
||||
|
||||
const proxy = buildProxyConfig(account)
|
||||
|
||||
if (account.proxy && account.proxy.url && (!proxy || !proxy.server)) {
|
||||
log('ERROR', 'Proxy is configured in account but proxy data is invalid or incomplete')
|
||||
log('ERROR', 'Account proxy config:', JSON.stringify(account.proxy, null, 2))
|
||||
log('ERROR', 'Required fields: proxy.url, proxy.port')
|
||||
log('ERROR', 'Cannot start browser without proxy when it is explicitly configured')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const userAgent = fingerprint?.fingerprint?.navigator?.userAgent || fingerprint?.fingerprint?.userAgent || null
|
||||
|
||||
log('INFO', `Session: ${args.email} (${sessionType})`)
|
||||
log('INFO', ` Cookies: ${cookies.length}`)
|
||||
log('INFO', ` Fingerprint: ${fingerprint ? 'Yes' : 'No'}`)
|
||||
log('INFO', ` User-Agent: ${userAgent || 'Default'}`)
|
||||
log('INFO', ` Proxy: ${proxy ? 'Yes' : 'No'}`)
|
||||
log('INFO', 'Launching browser...')
|
||||
|
||||
const browser = await chromium.launch({
|
||||
headless: false,
|
||||
...(proxy ? { proxy } : {}),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--mute-audio',
|
||||
'--disable-setuid-sandbox',
|
||||
'--ignore-certificate-errors',
|
||||
'--ignore-certificate-errors-spki-list',
|
||||
'--ignore-ssl-errors',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-user-media-security=true',
|
||||
'--disable-blink-features=Attestation',
|
||||
'--disable-features=WebAuthentication,PasswordManagerOnboarding,PasswordManager,EnablePasswordsAccountStorage,Passkeys',
|
||||
'--disable-save-password-bubble'
|
||||
]
|
||||
})
|
||||
|
||||
let context
|
||||
if (fingerprint) {
|
||||
context = await newInjectedContext(browser, { fingerprint })
|
||||
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'credentials', {
|
||||
value: {
|
||||
create: () => Promise.reject(new Error('WebAuthn disabled')),
|
||||
get: () => Promise.reject(new Error('WebAuthn disabled'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
log('SUCCESS', 'Fingerprint injected into browser context')
|
||||
} else {
|
||||
context = await browser.newContext({
|
||||
viewport: isMobile ? { width: 375, height: 667 } : { width: 1366, height: 768 }
|
||||
})
|
||||
}
|
||||
|
||||
if (cookies.length) {
|
||||
await context.addCookies(cookies)
|
||||
log('INFO', `Added ${cookies.length} cookies to context`)
|
||||
}
|
||||
|
||||
const page = await context.newPage()
|
||||
await page.goto(config.baseURL, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
log('SUCCESS', 'Browser opened with session loaded')
|
||||
log('INFO', `Navigated to: ${config.baseURL}`)
|
||||
|
||||
setupCleanupHandlers(async () => {
|
||||
if (browser?.isConnected?.()) {
|
||||
await browser.close()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
67
scripts/main/clearSessions.js
Normal file
67
scripts/main/clearSessions.js
Normal file
@@ -0,0 +1,67 @@
|
||||
import path from 'path'
|
||||
import fs from 'fs'
|
||||
import {
|
||||
getDirname,
|
||||
getProjectRoot,
|
||||
log,
|
||||
loadJsonFile,
|
||||
safeRemoveDirectory
|
||||
} from '../utils.js'
|
||||
|
||||
const __dirname = getDirname(import.meta.url)
|
||||
const projectRoot = getProjectRoot(__dirname)
|
||||
|
||||
const possibleConfigPaths = [
|
||||
path.join(projectRoot, 'config.json'),
|
||||
path.join(projectRoot, 'src', 'config.json'),
|
||||
path.join(projectRoot, 'dist', 'config.json')
|
||||
]
|
||||
|
||||
log('DEBUG', 'Project root:', projectRoot)
|
||||
log('DEBUG', 'Searching for config.json...')
|
||||
|
||||
const configResult = loadJsonFile(possibleConfigPaths, true)
|
||||
const config = configResult.data
|
||||
const configPath = configResult.path
|
||||
|
||||
log('INFO', 'Using config:', configPath)
|
||||
|
||||
if (!config.sessionPath) {
|
||||
log('ERROR', 'Invalid config.json - missing required field: sessionPath')
|
||||
log('ERROR', `Config file: ${configPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
log('INFO', 'Session path from config:', config.sessionPath)
|
||||
|
||||
const configDir = path.dirname(configPath)
|
||||
const possibleSessionDirs = [
|
||||
path.resolve(configDir, config.sessionPath),
|
||||
path.join(projectRoot, 'src/browser', config.sessionPath),
|
||||
path.join(projectRoot, 'dist/browser', config.sessionPath)
|
||||
]
|
||||
|
||||
log('DEBUG', 'Searching for session directory...')
|
||||
|
||||
let sessionDir = null
|
||||
for (const p of possibleSessionDirs) {
|
||||
log('DEBUG', 'Checking:', p)
|
||||
if (fs.existsSync(p)) {
|
||||
sessionDir = p
|
||||
log('DEBUG', 'Found session directory at:', p)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionDir) {
|
||||
sessionDir = path.resolve(configDir, config.sessionPath)
|
||||
log('DEBUG', 'Using fallback session directory:', sessionDir)
|
||||
}
|
||||
|
||||
const success = safeRemoveDirectory(sessionDir, projectRoot)
|
||||
|
||||
if (!success) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
log('INFO', 'Done.')
|
||||
3
scripts/nix/run.sh
Normal file
3
scripts/nix/run.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
nix develop --command bash -c "xvfb-run npm run start"
|
||||
269
scripts/utils.js
Normal file
269
scripts/utils.js
Normal file
@@ -0,0 +1,269 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
export function getDirname(importMetaUrl) {
|
||||
const __filename = fileURLToPath(importMetaUrl)
|
||||
return path.dirname(__filename)
|
||||
}
|
||||
|
||||
export function getProjectRoot(currentDir) {
|
||||
let dir = currentDir
|
||||
while (dir !== path.parse(dir).root) {
|
||||
if (fs.existsSync(path.join(dir, 'package.json'))) {
|
||||
return dir
|
||||
}
|
||||
dir = path.dirname(dir)
|
||||
}
|
||||
throw new Error('Could not find project root (package.json not found)')
|
||||
}
|
||||
|
||||
export function log(level, ...args) {
|
||||
console.log(`[${level}]`, ...args)
|
||||
}
|
||||
|
||||
export function parseArgs(argv = process.argv.slice(2)) {
|
||||
const args = {}
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i]
|
||||
|
||||
if (arg.startsWith('-')) {
|
||||
const key = arg.substring(1)
|
||||
|
||||
if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
|
||||
args[key] = argv[i + 1]
|
||||
i++
|
||||
} else {
|
||||
args[key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
export function validateEmail(email) {
|
||||
if (!email) {
|
||||
log('ERROR', 'Missing -email argument')
|
||||
log('ERROR', 'Usage: node script.js -email you@example.com')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (typeof email !== 'string') {
|
||||
log('ERROR', `Invalid email type: expected string, got ${typeof email}`)
|
||||
log('ERROR', 'Usage: node script.js -email you@example.com')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!email.includes('@')) {
|
||||
log('ERROR', `Invalid email format: "${email}"`)
|
||||
log('ERROR', 'Email must contain "@" symbol')
|
||||
log('ERROR', 'Example: you@example.com')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return email
|
||||
}
|
||||
|
||||
export function loadJsonFile(possiblePaths, required = true) {
|
||||
for (const filePath of possiblePaths) {
|
||||
if (fs.existsSync(filePath)) {
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, 'utf8')
|
||||
return { data: JSON.parse(content), path: filePath }
|
||||
} catch (error) {
|
||||
log('ERROR', `Failed to parse JSON file: ${filePath}`)
|
||||
log('ERROR', `Parse error: ${error.message}`)
|
||||
if (required) process.exit(1)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (required) {
|
||||
log('ERROR', 'Required file not found')
|
||||
log('ERROR', 'Searched in the following locations:')
|
||||
possiblePaths.forEach(p => log('ERROR', ` - ${p}`))
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function loadConfig(projectRoot, isDev = false) {
|
||||
const possiblePaths = isDev
|
||||
? [path.join(projectRoot, 'src', 'config.json')]
|
||||
: [
|
||||
path.join(projectRoot, 'dist', 'config.json'),
|
||||
path.join(projectRoot, 'config.json')
|
||||
]
|
||||
|
||||
const result = loadJsonFile(possiblePaths, true)
|
||||
|
||||
const missingFields = []
|
||||
if (!result.data.baseURL) missingFields.push('baseURL')
|
||||
if (!result.data.sessionPath) missingFields.push('sessionPath')
|
||||
if (result.data.headless === undefined) missingFields.push('headless')
|
||||
if (!result.data.workers) missingFields.push('workers')
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
log('ERROR', 'Invalid config.json - missing required fields:')
|
||||
missingFields.forEach(field => log('ERROR', ` - ${field}`))
|
||||
log('ERROR', `Config file: ${result.path}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadAccounts(projectRoot, isDev = false) {
|
||||
const possiblePaths = isDev
|
||||
? [path.join(projectRoot, 'src', 'accounts.dev.json')]
|
||||
: [
|
||||
path.join(projectRoot, 'dist', 'accounts.json'),
|
||||
path.join(projectRoot, 'accounts.json'),
|
||||
path.join(projectRoot, 'accounts.example.json')
|
||||
]
|
||||
|
||||
return loadJsonFile(possiblePaths, true)
|
||||
}
|
||||
|
||||
export function findAccountByEmail(accounts, email) {
|
||||
if (!email || typeof email !== 'string') return null
|
||||
return accounts.find(a => a?.email && typeof a.email === 'string' && a.email.toLowerCase() === email.toLowerCase()) || null
|
||||
}
|
||||
|
||||
export function getRuntimeBase(projectRoot, isDev = false) {
|
||||
return path.join(projectRoot, isDev ? 'src' : 'dist')
|
||||
}
|
||||
|
||||
export function getSessionPath(runtimeBase, sessionPath, email) {
|
||||
return path.join(runtimeBase, 'browser', sessionPath, email)
|
||||
}
|
||||
|
||||
export async function loadCookies(sessionBase, type = 'desktop') {
|
||||
const cookiesFile = path.join(sessionBase, `session_${type}.json`)
|
||||
|
||||
if (!fs.existsSync(cookiesFile)) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(cookiesFile, 'utf8')
|
||||
return JSON.parse(content)
|
||||
} catch (error) {
|
||||
log('WARN', `Failed to load cookies from: ${cookiesFile}`)
|
||||
log('WARN', `Error: ${error.message}`)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadFingerprint(sessionBase, type = 'desktop') {
|
||||
const fpFile = path.join(sessionBase, `session_fingerprint_${type}.json`)
|
||||
|
||||
if (!fs.existsSync(fpFile)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(fpFile, 'utf8')
|
||||
return JSON.parse(content)
|
||||
} catch (error) {
|
||||
log('WARN', `Failed to load fingerprint from: ${fpFile}`)
|
||||
log('WARN', `Error: ${error.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserAgent(fingerprint) {
|
||||
if (!fingerprint) return null
|
||||
return fingerprint?.fingerprint?.userAgent || fingerprint?.userAgent || null
|
||||
}
|
||||
|
||||
export function buildProxyConfig(account) {
|
||||
if (!account.proxy || !account.proxy.url || !account.proxy.port) {
|
||||
return null
|
||||
}
|
||||
|
||||
const proxy = {
|
||||
server: `${account.proxy.url}:${account.proxy.port}`
|
||||
}
|
||||
|
||||
if (account.proxy.username && account.proxy.password) {
|
||||
proxy.username = account.proxy.username
|
||||
proxy.password = account.proxy.password
|
||||
}
|
||||
|
||||
return proxy
|
||||
}
|
||||
|
||||
export function setupCleanupHandlers(cleanupFn) {
|
||||
const cleanup = async () => {
|
||||
try {
|
||||
await cleanupFn()
|
||||
} catch (error) {
|
||||
log('ERROR', 'Cleanup failed:', error.message)
|
||||
}
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
process.on('SIGINT', cleanup)
|
||||
process.on('SIGTERM', cleanup)
|
||||
}
|
||||
|
||||
export function validateDeletionPath(targetPath, projectRoot) {
|
||||
const normalizedTarget = path.normalize(targetPath)
|
||||
const normalizedRoot = path.normalize(projectRoot)
|
||||
|
||||
if (!normalizedTarget.startsWith(normalizedRoot)) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Path is outside project root'
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedTarget === normalizedRoot) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Cannot delete project root'
|
||||
}
|
||||
}
|
||||
|
||||
const pathSegments = normalizedTarget.split(path.sep)
|
||||
if (pathSegments.length < 3) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Path is too shallow (safety check failed)'
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true, error: null }
|
||||
}
|
||||
|
||||
export function safeRemoveDirectory(dirPath, projectRoot) {
|
||||
const validation = validateDeletionPath(dirPath, projectRoot)
|
||||
|
||||
if (!validation.valid) {
|
||||
log('ERROR', 'Directory deletion failed - safety check:')
|
||||
log('ERROR', ` Reason: ${validation.error}`)
|
||||
log('ERROR', ` Target: ${dirPath}`)
|
||||
log('ERROR', ` Project root: ${projectRoot}`)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
log('INFO', `Directory does not exist: ${dirPath}`)
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
fs.rmSync(dirPath, { recursive: true, force: true })
|
||||
log('SUCCESS', `Directory removed: ${dirPath}`)
|
||||
return true
|
||||
} catch (error) {
|
||||
log('ERROR', `Failed to remove directory: ${dirPath}`)
|
||||
log('ERROR', `Error: ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,39 @@
|
||||
{
|
||||
"email": "email_1",
|
||||
"password": "password_1",
|
||||
"totpSecret": "",
|
||||
"recoveryEmail": "",
|
||||
"geoLocale": "auto",
|
||||
"langCode": "en",
|
||||
"proxy": {
|
||||
"proxyAxios": false,
|
||||
"url": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"saveFingerprint": {
|
||||
"mobile": false,
|
||||
"desktop": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"email": "email_2",
|
||||
"password": "password_2",
|
||||
"totpSecret": "",
|
||||
"recoveryEmail": "",
|
||||
"geoLocale": "auto",
|
||||
"langCode": "en",
|
||||
"proxy": {
|
||||
"proxyAxios": false,
|
||||
"url": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": ""
|
||||
},
|
||||
"saveFingerprint": {
|
||||
"mobile": false,
|
||||
"desktop": false
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -1,57 +1,140 @@
|
||||
import puppeteer from 'puppeteer-extra'
|
||||
import { FingerprintInjector } from 'fingerprint-injector'
|
||||
import { FingerprintGenerator } from 'fingerprint-generator'
|
||||
import rebrowser, { BrowserContext } from 'patchright'
|
||||
import { newInjectedContext } from 'fingerprint-injector'
|
||||
import { BrowserFingerprintWithHeaders, FingerprintGenerator } from 'fingerprint-generator'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
import { loadSessionData, saveFingerprintData } from '../util/Load'
|
||||
import { UserAgentManager } from './UserAgent'
|
||||
|
||||
import { AccountProxy } from '../interface/Account'
|
||||
import type { Account, 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/
|
||||
*/
|
||||
|
||||
interface BrowserCreationResult {
|
||||
context: BrowserContext
|
||||
fingerprint: BrowserFingerprintWithHeaders
|
||||
}
|
||||
|
||||
class Browser {
|
||||
private bot: MicrosoftRewardsBot
|
||||
private readonly bot: MicrosoftRewardsBot
|
||||
private static readonly BROWSER_ARGS = [
|
||||
'--no-sandbox',
|
||||
'--mute-audio',
|
||||
'--disable-setuid-sandbox',
|
||||
'--ignore-certificate-errors',
|
||||
'--ignore-certificate-errors-spki-list',
|
||||
'--ignore-ssl-errors',
|
||||
'--no-first-run',
|
||||
'--no-default-browser-check',
|
||||
'--disable-user-media-security=true',
|
||||
'--disable-blink-features=Attestation',
|
||||
'--disable-features=WebAuthentication,PasswordManagerOnboarding,PasswordManager,EnablePasswordsAccountStorage,Passkeys',
|
||||
'--disable-save-password-bubble'
|
||||
] as const
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
async createBrowser(email: string, proxy: AccountProxy, isMobile: boolean) {
|
||||
// const userAgent = await getUserAgent(isMobile)
|
||||
async createBrowser(account: Account): Promise<BrowserCreationResult> {
|
||||
let browser: rebrowser.Browser
|
||||
try {
|
||||
const proxyConfig = account.proxy.url
|
||||
? {
|
||||
server: this.formatProxyServer(account.proxy),
|
||||
...(account.proxy.username &&
|
||||
account.proxy.password && {
|
||||
username: account.proxy.username,
|
||||
password: account.proxy.password
|
||||
})
|
||||
}
|
||||
: undefined
|
||||
|
||||
const browser = await puppeteer.launch({
|
||||
headless: this.bot.config.headless,
|
||||
userDataDir: await this.bot.browser.func.loadSesion(email),
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--mute-audio',
|
||||
'--disable-setuid-sandbox',
|
||||
proxy.url ? `--proxy-server=${proxy.url}:${proxy.port}` : ''
|
||||
]
|
||||
})
|
||||
|
||||
const { fingerprint, headers } = new FingerprintGenerator().getFingerprint({
|
||||
devices: isMobile ? ['mobile'] : ['desktop'],
|
||||
operatingSystems: isMobile ? ['android'] : ['windows'],
|
||||
browsers: ['edge'],
|
||||
browserListQuery: 'last 2 Edge versions'
|
||||
})
|
||||
|
||||
// Modify the newPage function to attach the fingerprint
|
||||
const originalNewPage = browser.newPage
|
||||
browser.newPage = async function () {
|
||||
const page = await originalNewPage.apply(browser)
|
||||
await new FingerprintInjector().attachFingerprintToPuppeteer(page, { fingerprint, headers })
|
||||
return page
|
||||
browser = await rebrowser.chromium.launch({
|
||||
headless: this.bot.config.headless,
|
||||
...(proxyConfig && { proxy: proxyConfig }),
|
||||
args: [...Browser.BROWSER_ARGS]
|
||||
})
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
this.bot.logger.error(this.bot.isMobile, 'BROWSER', `Launch failed: ${errorMessage}`)
|
||||
throw error
|
||||
}
|
||||
|
||||
return browser
|
||||
try {
|
||||
const sessionData = await loadSessionData(
|
||||
this.bot.config.sessionPath,
|
||||
account.email,
|
||||
account.saveFingerprint,
|
||||
this.bot.isMobile
|
||||
)
|
||||
|
||||
const fingerprint = sessionData.fingerprint ?? (await this.generateFingerprint(this.bot.isMobile))
|
||||
|
||||
const context = await newInjectedContext(browser as any, { fingerprint })
|
||||
|
||||
await context.addInitScript(() => {
|
||||
Object.defineProperty(navigator, 'credentials', {
|
||||
value: {
|
||||
create: () => Promise.reject(new Error('WebAuthn disabled')),
|
||||
get: () => Promise.reject(new Error('WebAuthn disabled'))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
context.setDefaultTimeout(this.bot.utils.stringToNumber(this.bot.config?.globalTimeout ?? 30000))
|
||||
|
||||
await context.addCookies(sessionData.cookies)
|
||||
|
||||
if (
|
||||
(account.saveFingerprint.mobile && this.bot.isMobile) ||
|
||||
(account.saveFingerprint.desktop && !this.bot.isMobile)
|
||||
) {
|
||||
await saveFingerprintData(this.bot.config.sessionPath, account.email, this.bot.isMobile, fingerprint)
|
||||
}
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'BROWSER',
|
||||
`Created browser with User-Agent: "${fingerprint.fingerprint.navigator.userAgent}"`
|
||||
)
|
||||
this.bot.logger.debug(this.bot.isMobile, 'BROWSER-FINGERPRINT', JSON.stringify(fingerprint))
|
||||
|
||||
return { context: context as unknown as BrowserContext, fingerprint }
|
||||
} catch (error) {
|
||||
await browser.close().catch(() => {})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private formatProxyServer(proxy: AccountProxy): string {
|
||||
try {
|
||||
const urlObj = new URL(proxy.url)
|
||||
const protocol = urlObj.protocol.replace(':', '')
|
||||
return `${protocol}://${urlObj.hostname}:${proxy.port}`
|
||||
} catch {
|
||||
return `${proxy.url}:${proxy.port}`
|
||||
}
|
||||
}
|
||||
|
||||
async generateFingerprint(isMobile: boolean) {
|
||||
const fingerPrintData = new FingerprintGenerator().getFingerprint({
|
||||
devices: isMobile ? ['mobile'] : ['desktop'],
|
||||
operatingSystems: isMobile ? ['android', 'ios'] : ['windows', 'linux'],
|
||||
browsers: [{ name: 'edge' }]
|
||||
})
|
||||
|
||||
const userAgentManager = new UserAgentManager(this.bot)
|
||||
const updatedFingerPrintData = await userAgentManager.updateFingerprintUserAgent(fingerPrintData, isMobile)
|
||||
|
||||
return updatedFingerPrintData
|
||||
}
|
||||
}
|
||||
|
||||
export default Browser
|
||||
export default Browser
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import { CheerioAPI, load } from 'cheerio'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { BrowserContext, Cookie } from 'patchright'
|
||||
import type { AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
|
||||
import { Counters, DashboardData, MorePromotion, PromotionalItem } from './../interface/DashboardData'
|
||||
import { QuizData } from './../interface/QuizData'
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
import { saveSessionData } from '../util/Load'
|
||||
|
||||
import type { Counters, DashboardData } from './../interface/DashboardData'
|
||||
import type { AppUserData } from '../interface/AppUserData'
|
||||
import type { XboxDashboardData } from '../interface/XboxDashboardData'
|
||||
import type { AppEarnablePoints, BrowserEarnablePoints, MissingSearchPoints } from '../interface/Points'
|
||||
import type { AppDashboardData } from '../interface/AppDashBoardData'
|
||||
|
||||
export default class BrowserFunc {
|
||||
private bot: MicrosoftRewardsBot
|
||||
@@ -16,239 +17,417 @@ export default class BrowserFunc {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
async goHome(page: Page): Promise<boolean> {
|
||||
|
||||
/**
|
||||
* Fetch user desktop dashboard data
|
||||
* @returns {DashboardData} Object of user bing rewards dashboard data
|
||||
*/
|
||||
async getDashboardData(): Promise<DashboardData> {
|
||||
try {
|
||||
const dashboardURL = new URL(this.bot.config.baseURL)
|
||||
const cookieHeader = this.bot.cookies.mobile
|
||||
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||
.join('; ')
|
||||
|
||||
await page.goto(this.bot.config.baseURL)
|
||||
|
||||
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: 3000 }).then(() => true).catch(() => false)
|
||||
if (isSuspended) {
|
||||
this.bot.log('GO-HOME', 'This account is suspended!', 'error')
|
||||
throw new Error('Account has been suspended!')
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://rewards.bing.com/api/getuserinfo?type=1',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(this.bot.fingerprint?.headers ?? {}),
|
||||
Cookie: cookieHeader,
|
||||
Referer: 'https://rewards.bing.com/',
|
||||
Origin: 'https://rewards.bing.com'
|
||||
}
|
||||
|
||||
try {
|
||||
// If activities are found, exit the loop
|
||||
await page.waitForSelector('#more-activities', { timeout: 1000 })
|
||||
break
|
||||
|
||||
} catch (error) {
|
||||
// Continue if element is not found
|
||||
}
|
||||
|
||||
const currentURL = new URL(page.url())
|
||||
|
||||
if (currentURL.hostname !== dashboardURL.hostname) {
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
|
||||
await this.bot.utils.wait(2000)
|
||||
await page.goto(this.bot.config.baseURL)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(5000)
|
||||
this.bot.log('GO-HOME', 'Visited homepage successfully')
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
return response.data.dashboard as DashboardData
|
||||
} catch (error) {
|
||||
console.error('An error occurred:', error)
|
||||
return false
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'GET-DASHBOARD-DATA',
|
||||
`Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async getDashboardData(page: Page): Promise<DashboardData> {
|
||||
const dashboardURL = new URL(this.bot.config.baseURL)
|
||||
const currentURL = new URL(page.url())
|
||||
|
||||
// Should never happen since tasks are opened in a new tab!
|
||||
if (currentURL.hostname !== dashboardURL.hostname) {
|
||||
this.bot.log('DASHBOARD-DATA', 'Provided page did not equal dashboard page, redirecting to dashboard page')
|
||||
await this.goHome(page)
|
||||
}
|
||||
|
||||
// Reload the page to get new data
|
||||
await page.reload({ waitUntil: 'networkidle2' })
|
||||
|
||||
const scriptContent = await page.evaluate(() => {
|
||||
const scripts = Array.from(document.querySelectorAll('script'))
|
||||
const targetScript = scripts.find(script => script.innerText.includes('var dashboard'))
|
||||
|
||||
if (targetScript) {
|
||||
return targetScript.innerText
|
||||
} else {
|
||||
throw this.bot.log('GET-DASHBOARD-DATA', 'Script containing dashboard data not found', 'error')
|
||||
}
|
||||
})
|
||||
|
||||
// Extract the dashboard object from the script content
|
||||
const dashboardData = await page.evaluate(scriptContent => {
|
||||
// Extract the dashboard object using regex
|
||||
const regex = /var dashboard = (\{.*?\});/s
|
||||
const match = regex.exec(scriptContent)
|
||||
|
||||
if (match && match[1]) {
|
||||
return JSON.parse(match[1])
|
||||
} else {
|
||||
throw this.bot.log('GET-DASHBOARD-DATA', 'Dashboard data not found within script', 'error')
|
||||
}
|
||||
}, scriptContent)
|
||||
|
||||
return dashboardData
|
||||
}
|
||||
|
||||
async getQuizData(page: Page): Promise<QuizData> {
|
||||
/**
|
||||
* Fetch user app dashboard data
|
||||
* @returns {AppDashboardData} Object of user bing rewards dashboard data
|
||||
*/
|
||||
async getAppDashboardData(): Promise<AppDashboardData> {
|
||||
try {
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
|
||||
const scriptContent = $('script').filter((index, element) => {
|
||||
return $(element).text().includes('_w.rewardsQuizRenderInfo')
|
||||
}).text()
|
||||
|
||||
if (scriptContent) {
|
||||
const regex = /_w\.rewardsQuizRenderInfo\s*=\s*({.*?});/s
|
||||
const match = regex.exec(scriptContent)
|
||||
|
||||
if (match && match[1]) {
|
||||
const quizData = JSON.parse(match[1])
|
||||
return quizData
|
||||
} else {
|
||||
throw this.bot.log('GET-QUIZ-DATA', 'Quiz data not found within script', 'error')
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAIOS&options=613',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||
'User-Agent':
|
||||
'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2'
|
||||
}
|
||||
} else {
|
||||
throw this.bot.log('GET-QUIZ-DATA', 'Script containing quiz data not found', 'error')
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
return response.data as AppDashboardData
|
||||
} catch (error) {
|
||||
throw this.bot.log('GET-QUIZ-DATA', 'An error occurred:' + error, 'error')
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'GET-APP-DASHBOARD-DATA',
|
||||
`Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async getSearchPoints(page: Page): Promise<Counters> {
|
||||
const dashboardData = await this.getDashboardData(page) // Always fetch newest data
|
||||
/**
|
||||
* Fetch user xbox dashboard data
|
||||
* @returns {XboxDashboardData} Object of user bing rewards dashboard data
|
||||
*/
|
||||
async getXBoxDashboardData(): Promise<XboxDashboardData> {
|
||||
try {
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=xboxapp&options=6',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; Xbox; Xbox One X) AppleWebKit/537.36 (KHTML, like Gecko) Edge/18.19041'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
return response.data as XboxDashboardData
|
||||
} catch (error) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'GET-XBOX-DASHBOARD-DATA',
|
||||
`Error fetching dashboard data: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search point counters
|
||||
*/
|
||||
async getSearchPoints(): Promise<Counters> {
|
||||
const dashboardData = await this.getDashboardData() // Always fetch newest data
|
||||
|
||||
return dashboardData.userStatus.counters
|
||||
}
|
||||
|
||||
async getEarnablePoints(data: DashboardData, page: null | Page = null): Promise<number> {
|
||||
missingSearchPoints(counters: Counters, isMobile: boolean): MissingSearchPoints {
|
||||
const mobileData = counters.mobileSearch?.[0]
|
||||
const desktopData = counters.pcSearch?.[0]
|
||||
const edgeData = counters.pcSearch?.[1]
|
||||
|
||||
const mobilePoints = mobileData ? Math.max(0, mobileData.pointProgressMax - mobileData.pointProgress) : 0
|
||||
const desktopPoints = desktopData ? Math.max(0, desktopData.pointProgressMax - desktopData.pointProgress) : 0
|
||||
const edgePoints = edgeData ? Math.max(0, edgeData.pointProgressMax - edgeData.pointProgress) : 0
|
||||
|
||||
const totalPoints = isMobile ? mobilePoints : desktopPoints + edgePoints
|
||||
|
||||
return { mobilePoints, desktopPoints, edgePoints, totalPoints }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get total earnable points with web browser
|
||||
*/
|
||||
async getBrowserEarnablePoints(): Promise<BrowserEarnablePoints> {
|
||||
try {
|
||||
// Fetch new data if page is provided
|
||||
if (page) {
|
||||
data = await this.getDashboardData(page)
|
||||
const data = await this.getDashboardData()
|
||||
|
||||
const desktopSearchPoints =
|
||||
data.userStatus.counters.pcSearch?.reduce(
|
||||
(sum, x) => sum + (x.pointProgressMax - x.pointProgress),
|
||||
0
|
||||
) ?? 0
|
||||
|
||||
const mobileSearchPoints =
|
||||
data.userStatus.counters.mobileSearch?.reduce(
|
||||
(sum, x) => sum + (x.pointProgressMax - x.pointProgress),
|
||||
0
|
||||
) ?? 0
|
||||
|
||||
const todayDate = this.bot.utils.getFormattedDate()
|
||||
const dailySetPoints =
|
||||
data.dailySetPromotions[todayDate]?.reduce(
|
||||
(sum, x) => sum + (x.pointProgressMax - x.pointProgress),
|
||||
0
|
||||
) ?? 0
|
||||
|
||||
const morePromotionsPoints =
|
||||
data.morePromotions?.reduce((sum, x) => {
|
||||
if (
|
||||
['quiz', 'urlreward'].includes(x.promotionType) &&
|
||||
x.exclusiveLockedFeatureStatus !== 'locked'
|
||||
) {
|
||||
return sum + (x.pointProgressMax - x.pointProgress)
|
||||
}
|
||||
return sum
|
||||
}, 0) ?? 0
|
||||
|
||||
const totalEarnablePoints = desktopSearchPoints + mobileSearchPoints + dailySetPoints + morePromotionsPoints
|
||||
|
||||
return {
|
||||
dailySetPoints,
|
||||
morePromotionsPoints,
|
||||
desktopSearchPoints,
|
||||
mobileSearchPoints,
|
||||
totalEarnablePoints
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
// Mobile Search Points
|
||||
if (data.userStatus.counters.mobileSearch?.length) {
|
||||
data.userStatus.counters.mobileSearch.forEach(x => totalEarnablePoints += (x.pointProgressMax - x.pointProgress))
|
||||
}
|
||||
|
||||
// Daily Set
|
||||
data.dailySetPromotions[this.bot.utils.getFormattedDate()]?.forEach(x => totalEarnablePoints += (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)
|
||||
}
|
||||
})
|
||||
|
||||
return totalEarnablePoints
|
||||
} catch (error) {
|
||||
throw this.bot.log('GET-EARNABLE-POINTS', 'An error occurred:' + error, 'error')
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'GET-BROWSER-EARNABLE-POINTS',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getCurrentPoints(data: DashboardData, page: null | Page = null): Promise<number> {
|
||||
/**
|
||||
* Get total earnable points with mobile app
|
||||
*/
|
||||
async getAppEarnablePoints(): Promise<AppEarnablePoints> {
|
||||
try {
|
||||
// Fetch new data if page is provided
|
||||
if (page) {
|
||||
data = await this.getDashboardData(page)
|
||||
const eligibleOffers = ['ENUS_readarticle3_30points', 'Gamification_Sapphire_DailyCheckIn']
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me?channel=SAAndroid&options=613',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||
'X-Rewards-Country': this.bot.userData.geoLocale,
|
||||
'X-Rewards-Language': 'en',
|
||||
'X-Rewards-ismobile': 'true'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
const userData: AppUserData = response.data
|
||||
const eligibleActivities = userData.response.promotions.filter(x =>
|
||||
eligibleOffers.includes(x.attributes.offerid ?? '')
|
||||
)
|
||||
|
||||
let readToEarn = 0
|
||||
let checkIn = 0
|
||||
|
||||
for (const item of eligibleActivities) {
|
||||
const attrs = item.attributes
|
||||
|
||||
if (attrs.type === 'msnreadearn') {
|
||||
const pointMax = parseInt(attrs.pointmax ?? '0')
|
||||
const pointProgress = parseInt(attrs.pointprogress ?? '0')
|
||||
readToEarn = Math.max(0, pointMax - pointProgress)
|
||||
} else if (attrs.type === 'checkin') {
|
||||
const progress = parseInt(attrs.progress ?? '0')
|
||||
const checkInDay = progress % 7
|
||||
const lastUpdated = new Date(attrs.last_updated ?? '')
|
||||
const today = new Date()
|
||||
|
||||
if (checkInDay < 6 && today.getDate() !== lastUpdated.getDate()) {
|
||||
checkIn = parseInt(attrs[`day_${checkInDay + 1}_points`] ?? '0')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalEarnablePoints = readToEarn + checkIn
|
||||
|
||||
return {
|
||||
readToEarn,
|
||||
checkIn,
|
||||
totalEarnablePoints
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'GET-APP-EARNABLE-POINTS',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Get current point amount
|
||||
* @returns {number} Current total point amount
|
||||
*/
|
||||
async getCurrentPoints(): Promise<number> {
|
||||
try {
|
||||
const data = await this.getDashboardData()
|
||||
|
||||
return data.userStatus.availablePoints
|
||||
} catch (error) {
|
||||
throw this.bot.log('GET-CURRENT-POINTS', 'An error occurred:' + error, 'error')
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'GET-CURRENT-POINTS',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async loadSesion(email: string): Promise<string> {
|
||||
const sessionDir = path.join(__dirname, this.bot.config.sessionPath, email)
|
||||
|
||||
async closeBrowser(browser: BrowserContext, email: string) {
|
||||
try {
|
||||
// Create session dir
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
await fs.promises.mkdir(sessionDir, { recursive: true })
|
||||
}
|
||||
const cookies = await browser.cookies()
|
||||
|
||||
return sessionDir
|
||||
} catch (error) {
|
||||
throw new Error(error as string)
|
||||
}
|
||||
}
|
||||
// Save cookies
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'CLOSE-BROWSER',
|
||||
`Saving ${cookies.length} cookies to session folder!`
|
||||
)
|
||||
await saveSessionData(this.bot.config.sessionPath, cookies, email, this.bot.isMobile)
|
||||
|
||||
async waitForQuizRefresh(page: Page): Promise<boolean> {
|
||||
try {
|
||||
await page.waitForSelector('#rqHeaderCredits', { visible: true, timeout: 10_000 })
|
||||
await this.bot.utils.wait(2000)
|
||||
|
||||
return true
|
||||
// Close browser
|
||||
await browser.close()
|
||||
this.bot.logger.info(this.bot.isMobile, 'CLOSE-BROWSER', 'Browser closed cleanly!')
|
||||
} catch (error) {
|
||||
this.bot.log('QUIZ-REFRESH', 'An error occurred:' + error, 'error')
|
||||
return false
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'CLOSE-BROWSER',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async checkQuizCompleted(page: Page): Promise<boolean> {
|
||||
try {
|
||||
await page.waitForSelector('#quizCompleteContainer', { visible: true, timeout: 2000 })
|
||||
await this.bot.utils.wait(2000)
|
||||
mergeCookies(response: AxiosResponse, currentCookieHeader: string = '', whitelist?: string[]): string {
|
||||
const cookieMap = new Map<string, string>(
|
||||
currentCookieHeader
|
||||
.split(';')
|
||||
.map(pair => pair.split('=').map(s => s.trim()))
|
||||
.filter(([name, value]) => name && value)
|
||||
.map(([name, value]) => [name, value] as [string, string])
|
||||
)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
const setCookieList = [response.headers['set-cookie']].flat().filter(Boolean) as string[]
|
||||
const cookiesByName = new Map(this.bot.cookies.mobile.map(c => [c.name, c]))
|
||||
|
||||
async refreshCheerio(page: Page): Promise<CheerioAPI> {
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
for (const setCookie of setCookieList) {
|
||||
const [nameValue, ...attributes] = setCookie.split(';').map(s => s.trim())
|
||||
if (!nameValue) continue
|
||||
|
||||
return $
|
||||
}
|
||||
const [name, value] = nameValue.split('=').map(s => s.trim())
|
||||
|
||||
async getPunchCardActivity(page: Page, activity: PromotionalItem | MorePromotion): Promise<string> {
|
||||
let selector = ''
|
||||
try {
|
||||
const html = await page.content()
|
||||
const $ = load(html)
|
||||
if (!name) continue
|
||||
|
||||
const element = $('.offer-cta').toArray().find(x => x.attribs.href?.includes(activity.offerId))
|
||||
if (element) {
|
||||
selector = `a[href*="${element.attribs.href}"]`
|
||||
if (whitelist && !whitelist?.includes(name)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const attrs = this.parseAttributes(attributes)
|
||||
const existing = cookiesByName.get(name)
|
||||
|
||||
if (!value) {
|
||||
if (existing) {
|
||||
cookiesByName.delete(name)
|
||||
this.bot.cookies.mobile = this.bot.cookies.mobile.filter(c => c.name !== name)
|
||||
}
|
||||
cookieMap.delete(name)
|
||||
continue
|
||||
}
|
||||
|
||||
if (attrs.expires !== undefined && attrs.expires < Date.now() / 1000) {
|
||||
if (existing) {
|
||||
cookiesByName.delete(name)
|
||||
this.bot.cookies.mobile = this.bot.cookies.mobile.filter(c => c.name !== name)
|
||||
}
|
||||
cookieMap.delete(name)
|
||||
continue
|
||||
}
|
||||
|
||||
cookieMap.set(name, value)
|
||||
|
||||
if (existing) {
|
||||
this.updateCookie(existing, value, attrs)
|
||||
} else {
|
||||
this.bot.cookies.mobile.push(this.createCookie(name, value, attrs))
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.log('GET-PUNCHCARD-ACTIVITY', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
|
||||
return selector
|
||||
return Array.from(cookieMap, ([name, value]) => `${name}=${value}`).join('; ')
|
||||
}
|
||||
|
||||
}
|
||||
private parseAttributes(attributes: string[]) {
|
||||
const attrs: {
|
||||
domain?: string
|
||||
path?: string
|
||||
expires?: number
|
||||
httpOnly?: boolean
|
||||
secure?: boolean
|
||||
sameSite?: Cookie['sameSite']
|
||||
} = {}
|
||||
|
||||
for (const attr of attributes) {
|
||||
const [key, val] = attr.split('=').map(s => s?.trim())
|
||||
const lowerKey = key?.toLowerCase()
|
||||
|
||||
switch (lowerKey) {
|
||||
case 'domain':
|
||||
case 'path': {
|
||||
if (val) attrs[lowerKey] = val
|
||||
break
|
||||
}
|
||||
case 'expires': {
|
||||
if (val) {
|
||||
const ts = Date.parse(val)
|
||||
if (!isNaN(ts)) attrs.expires = Math.floor(ts / 1000)
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'max-age': {
|
||||
if (val) {
|
||||
const maxAge = Number(val)
|
||||
if (!isNaN(maxAge)) attrs.expires = Math.floor(Date.now() / 1000) + maxAge
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'httponly': {
|
||||
attrs.httpOnly = true
|
||||
break
|
||||
}
|
||||
case 'secure': {
|
||||
attrs.secure = true
|
||||
break
|
||||
}
|
||||
case 'samesite': {
|
||||
const normalized = val?.toLowerCase()
|
||||
if (normalized && ['lax', 'strict', 'none'].includes(normalized)) {
|
||||
attrs.sameSite = (normalized.charAt(0).toUpperCase() +
|
||||
normalized.slice(1)) as Cookie['sameSite']
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return attrs
|
||||
}
|
||||
|
||||
private updateCookie(cookie: Cookie, value: string, attrs: ReturnType<typeof this.parseAttributes>) {
|
||||
cookie.value = value
|
||||
if (attrs.domain) cookie.domain = attrs.domain
|
||||
if (attrs.path) cookie.path = attrs.path
|
||||
//if (attrs.expires !== undefined) cookie.expires = attrs.expires
|
||||
//if (attrs.httpOnly) cookie.httpOnly = true
|
||||
//if (attrs.secure) cookie.secure = true
|
||||
//if (attrs.sameSite) cookie.sameSite = attrs.sameSite
|
||||
}
|
||||
|
||||
private createCookie(name: string, value: string, attrs: ReturnType<typeof this.parseAttributes>): Cookie {
|
||||
return {
|
||||
name,
|
||||
value,
|
||||
domain: attrs.domain || '.bing.com',
|
||||
path: attrs.path || '/'
|
||||
/*
|
||||
...(attrs.expires !== undefined && { expires: attrs.expires }),
|
||||
...(attrs.httpOnly && { httpOnly: true }),
|
||||
...(attrs.secure && { secure: true }),
|
||||
...(attrs.sameSite && { sameSite: attrs.sameSite })
|
||||
*/
|
||||
} as Cookie
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Page } from 'puppeteer'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
|
||||
|
||||
export default class BrowserUtil {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
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 tryDismissBingCookieBanner(page: Page): Promise<void> {
|
||||
try {
|
||||
await page.waitForSelector('#bnp_btn_accept', { timeout: 1000 })
|
||||
const cookieBanner = await page.$('#bnp_btn_accept')
|
||||
|
||||
if (cookieBanner) {
|
||||
await cookieBanner.click()
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue if element is not found or other error occurs
|
||||
}
|
||||
}
|
||||
|
||||
async getLatestTab(page: Page): Promise<Page> {
|
||||
try {
|
||||
await this.bot.utils.wait(500)
|
||||
|
||||
const browser = page.browser()
|
||||
const pages = await 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')
|
||||
} catch (error) {
|
||||
throw this.bot.log('GET-NEW-TAB', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
273
src/browser/BrowserUtils.ts
Normal file
273
src/browser/BrowserUtils.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { type Page, type BrowserContext } from 'patchright'
|
||||
import { CheerioAPI, load } from 'cheerio'
|
||||
import { ClickOptions, createCursor } from 'ghost-cursor-playwright-port'
|
||||
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
|
||||
export default class BrowserUtils {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
async tryDismissAllMessages(page: Page): Promise<void> {
|
||||
try {
|
||||
const buttons = [
|
||||
{ selector: '#acceptButton', label: 'AcceptButton' },
|
||||
{ selector: '#wcpConsentBannerCtrl > * > button:first-child', label: 'Bing Cookies Accept' },
|
||||
{ selector: '.ext-secondary.ext-button', label: '"Skip for now" Button' },
|
||||
{ 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' },
|
||||
{ selector: '.c-glyph.glyph-cancel', label: 'Mobile Welcome Button' },
|
||||
{ selector: '.maybe-later', label: 'Mobile Rewards App Banner' },
|
||||
{ selector: '#bnp_btn_accept', label: 'Bing Cookie Banner' },
|
||||
{ selector: '#reward_pivot_earn', label: 'Reward Coupon Accept' }
|
||||
]
|
||||
|
||||
const checkVisible = await Promise.allSettled(
|
||||
buttons.map(async b => ({
|
||||
...b,
|
||||
isVisible: await page
|
||||
.locator(b.selector)
|
||||
.isVisible()
|
||||
.catch(() => false)
|
||||
}))
|
||||
)
|
||||
|
||||
const visibleButtons = checkVisible
|
||||
.filter(r => r.status === 'fulfilled' && r.value.isVisible)
|
||||
.map(r => (r.status === 'fulfilled' ? r.value : null))
|
||||
.filter(Boolean)
|
||||
|
||||
if (visibleButtons.length > 0) {
|
||||
await Promise.allSettled(
|
||||
visibleButtons.map(async b => {
|
||||
if (b) {
|
||||
const clicked = await this.ghostClick(page, b.selector)
|
||||
if (clicked) {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DISMISS-ALL-MESSAGES',
|
||||
`Dismissed: ${b.label}`
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
await this.bot.utils.wait(300)
|
||||
}
|
||||
|
||||
// Overlay
|
||||
const overlay = await page.$('#bnp_overlay_wrapper')
|
||||
if (overlay) {
|
||||
const rejected = await this.ghostClick(page, '#bnp_btn_reject, button[aria-label*="Reject" i]')
|
||||
if (rejected) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DISMISS-ALL-MESSAGES', 'Dismissed: Bing Overlay Reject')
|
||||
} else {
|
||||
const accepted = await this.ghostClick(page, '#bnp_btn_accept')
|
||||
if (accepted) {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DISMISS-ALL-MESSAGES',
|
||||
'Dismissed: Bing Overlay Accept'
|
||||
)
|
||||
}
|
||||
}
|
||||
await this.bot.utils.wait(250)
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'DISMISS-ALL-MESSAGES',
|
||||
`Handler error: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async getLatestTab(page: Page): Promise<Page> {
|
||||
try {
|
||||
const browser: BrowserContext = page.context()
|
||||
const pages = browser.pages()
|
||||
|
||||
const newTab = pages[pages.length - 1]
|
||||
if (!newTab) {
|
||||
throw this.bot.logger.error(this.bot.isMobile, 'GET-NEW-TAB', 'No tabs could be found!')
|
||||
}
|
||||
|
||||
return newTab
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'GET-NEW-TAB',
|
||||
`Unable to get latest tab: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async reloadBadPage(page: Page): Promise<boolean> {
|
||||
try {
|
||||
const html = await page.content().catch(() => '')
|
||||
const $ = load(html)
|
||||
|
||||
if ($('body.neterror').length) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'RELOAD-BAD-PAGE', 'Bad page detected, reloading!')
|
||||
try {
|
||||
await page.reload({ waitUntil: 'load' })
|
||||
} catch {
|
||||
await page.reload().catch(() => {})
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'RELOAD-BAD-PAGE',
|
||||
`Reload check failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
async closeTabs(page: Page, config = { minTabs: 1, maxTabs: 1 }): Promise<Page> {
|
||||
try {
|
||||
const browser = page.context()
|
||||
const tabs = browser.pages()
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-CLOSE-TABS',
|
||||
`Found ${tabs.length} tab(s) open (min: ${config.minTabs}, max: ${config.maxTabs})`
|
||||
)
|
||||
|
||||
// Check if valid
|
||||
if (config.minTabs < 1 || config.maxTabs < config.minTabs) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-CLOSE-TABS', 'Invalid config, using defaults')
|
||||
config = { minTabs: 1, maxTabs: 1 }
|
||||
}
|
||||
|
||||
// Close if more than max config
|
||||
if (tabs.length > config.maxTabs) {
|
||||
const tabsToClose = tabs.slice(config.maxTabs)
|
||||
|
||||
const closeResults = await Promise.allSettled(tabsToClose.map(tab => tab.close()))
|
||||
|
||||
const closedCount = closeResults.filter(r => r.status === 'fulfilled').length
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-CLOSE-TABS',
|
||||
`Closed ${closedCount}/${tabsToClose.length} excess tab(s) to reach max of ${config.maxTabs}`
|
||||
)
|
||||
|
||||
// Open more tabs
|
||||
} else if (tabs.length < config.minTabs) {
|
||||
const tabsNeeded = config.minTabs - tabs.length
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-CLOSE-TABS',
|
||||
`Opening ${tabsNeeded} tab(s) to reach min of ${config.minTabs}`
|
||||
)
|
||||
|
||||
const newTabPromises = Array.from({ length: tabsNeeded }, async () => {
|
||||
try {
|
||||
const newPage = await browser.newPage()
|
||||
await newPage.goto(this.bot.config.baseURL, { waitUntil: 'domcontentloaded', timeout: 15000 })
|
||||
return newPage
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-CLOSE-TABS',
|
||||
`Failed to create new tab: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
await Promise.allSettled(newTabPromises)
|
||||
}
|
||||
|
||||
const latestTab = await this.getLatestTab(page)
|
||||
return latestTab
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-CLOSE-TABS',
|
||||
`Error: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return page
|
||||
}
|
||||
}
|
||||
|
||||
async loadInCheerio(data: Page | string): Promise<CheerioAPI> {
|
||||
const html: string = typeof data === 'string' ? data : await data.content()
|
||||
const $ = load(html)
|
||||
return $
|
||||
}
|
||||
|
||||
async ghostClick(page: Page, selector: string, options?: ClickOptions): Promise<boolean> {
|
||||
try {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'GHOST-CLICK',
|
||||
`Trying to click selector: ${selector}, options: ${JSON.stringify(options)}`
|
||||
)
|
||||
|
||||
// Wait for selector to exist before clicking
|
||||
await page.waitForSelector(selector, { timeout: 10000 })
|
||||
|
||||
const cursor = createCursor(page as any)
|
||||
await cursor.click(selector, options)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'GHOST-CLICK',
|
||||
`Failed for ${selector}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async disableFido(page: Page) {
|
||||
const routePattern = '**/GetCredentialType.srf*'
|
||||
await page.route(routePattern, route => {
|
||||
try {
|
||||
const request = route.request()
|
||||
const postData = request.postData()
|
||||
|
||||
const body = postData ? JSON.parse(postData) : {}
|
||||
|
||||
body.isFidoSupported = false
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DISABLE-FIDO',
|
||||
`Modified request body: isFidoSupported set to ${body.isFidoSupported}`
|
||||
)
|
||||
|
||||
route.continue({
|
||||
postData: JSON.stringify(body),
|
||||
headers: {
|
||||
...request.headers(),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DISABLE-FIDO',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
route.continue()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
164
src/browser/UserAgent.ts
Normal file
164
src/browser/UserAgent.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import axios from 'axios'
|
||||
import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
||||
|
||||
import type { ChromeVersion, EdgeVersion } from '../interface/UserAgentUtil'
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
|
||||
export class UserAgentManager {
|
||||
private static readonly NOT_A_BRAND_VERSION = '99'
|
||||
|
||||
constructor(private bot: MicrosoftRewardsBot) {}
|
||||
|
||||
async getUserAgent(isMobile: boolean) {
|
||||
const system = this.getSystemComponents(isMobile)
|
||||
const app = await this.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 = `${isMobile ? Math.floor(Math.random() * 5) + 9 : Math.floor(Math.random() * 15) + 1}.0.0`
|
||||
|
||||
const uaMetadata = {
|
||||
isMobile,
|
||||
platform: isMobile ? 'Android' : 'Windows',
|
||||
fullVersionList: [
|
||||
{ brand: 'Not/A)Brand', version: `${UserAgentManager.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: UserAgentManager.NOT_A_BRAND_VERSION },
|
||||
{ brand: 'Microsoft Edge', version: app['edge_major_version'] },
|
||||
{ brand: 'Chromium', version: app['chrome_major_version'] }
|
||||
],
|
||||
platformVersion,
|
||||
architecture: isMobile ? '' : 'x86',
|
||||
bitness: isMobile ? '' : '64',
|
||||
model: ''
|
||||
}
|
||||
|
||||
return { userAgent: uaTemplate, userAgentMetadata: uaMetadata }
|
||||
}
|
||||
|
||||
async getChromeVersion(isMobile: boolean): Promise<string> {
|
||||
try {
|
||||
const request = {
|
||||
url: 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios(request)
|
||||
const data: ChromeVersion = response.data
|
||||
return data.channels.Stable.version
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
isMobile,
|
||||
'USERAGENT-CHROME-VERSION',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async getEdgeVersions(isMobile: boolean) {
|
||||
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) {
|
||||
this.bot.logger.error(
|
||||
isMobile,
|
||||
'USERAGENT-EDGE-VERSION',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
getSystemComponents(mobile: boolean): string {
|
||||
if (mobile) {
|
||||
const androidVersion = 10 + Math.floor(Math.random() * 5)
|
||||
return `Linux; Android ${androidVersion}; K`
|
||||
}
|
||||
|
||||
return 'Windows NT 10.0; Win64; x64'
|
||||
}
|
||||
|
||||
async getAppComponents(isMobile: boolean) {
|
||||
const versions = await this.getEdgeVersions(isMobile)
|
||||
const edgeVersion = isMobile ? versions.android : (versions.windows as string)
|
||||
const edgeMajorVersion = edgeVersion?.split('.')[0]
|
||||
|
||||
const chromeVersion = await this.getChromeVersion(isMobile)
|
||||
const chromeMajorVersion = chromeVersion?.split('.')[0]
|
||||
const chromeReducedVersion = `${chromeMajorVersion}.0.0.0`
|
||||
|
||||
return {
|
||||
not_a_brand_version: `${UserAgentManager.NOT_A_BRAND_VERSION}.0.0.0`,
|
||||
not_a_brand_major_version: UserAgentManager.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 updateFingerprintUserAgent(
|
||||
fingerprint: BrowserFingerprintWithHeaders,
|
||||
isMobile: boolean
|
||||
): Promise<BrowserFingerprintWithHeaders> {
|
||||
try {
|
||||
const userAgentData = await this.getUserAgent(isMobile)
|
||||
const componentData = await this.getAppComponents(isMobile)
|
||||
|
||||
//@ts-expect-error Errors due it not exactly matching
|
||||
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) {
|
||||
this.bot.logger.error(
|
||||
isMobile,
|
||||
'USER-AGENT-UPDATE',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
619
src/browser/auth/Login.ts
Normal file
619
src/browser/auth/Login.ts
Normal file
@@ -0,0 +1,619 @@
|
||||
import type { Page } from 'patchright'
|
||||
import type { MicrosoftRewardsBot } from '../../index'
|
||||
import { saveSessionData } from '../../util/Load'
|
||||
|
||||
import { MobileAccessLogin } from './methods/MobileAccessLogin'
|
||||
import { EmailLogin } from './methods/EmailLogin'
|
||||
import { PasswordlessLogin } from './methods/PasswordlessLogin'
|
||||
import { TotpLogin } from './methods/Totp2FALogin'
|
||||
import { CodeLogin } from './methods/GetACodeLogin'
|
||||
import { RecoveryLogin } from './methods/RecoveryEmailLogin'
|
||||
|
||||
import type { Account } from '../../interface/Account'
|
||||
|
||||
type LoginState =
|
||||
| 'EMAIL_INPUT'
|
||||
| 'PASSWORD_INPUT'
|
||||
| 'SIGN_IN_ANOTHER_WAY'
|
||||
| 'SIGN_IN_ANOTHER_WAY_EMAIL'
|
||||
| 'PASSKEY_ERROR'
|
||||
| 'PASSKEY_VIDEO'
|
||||
| 'KMSI_PROMPT'
|
||||
| 'LOGGED_IN'
|
||||
| 'RECOVERY_EMAIL_INPUT'
|
||||
| 'ACCOUNT_LOCKED'
|
||||
| 'ERROR_ALERT'
|
||||
| '2FA_TOTP'
|
||||
| 'LOGIN_PASSWORDLESS'
|
||||
| 'GET_A_CODE'
|
||||
| 'GET_A_CODE_2'
|
||||
| 'UNKNOWN'
|
||||
| 'CHROMEWEBDATA_ERROR'
|
||||
|
||||
export class Login {
|
||||
emailLogin: EmailLogin
|
||||
passwordlessLogin: PasswordlessLogin
|
||||
totp2FALogin: TotpLogin
|
||||
codeLogin: CodeLogin
|
||||
recoveryLogin: RecoveryLogin
|
||||
|
||||
private readonly selectors = {
|
||||
primaryButton: 'button[data-testid="primaryButton"]',
|
||||
secondaryButton: 'button[data-testid="secondaryButton"]',
|
||||
emailIcon: '[data-testid="tile"]:has(svg path[d*="M5.25 4h13.5a3.25"])',
|
||||
emailIconOld: 'img[data-testid="accessibleImg"][src*="picker_verify_email"]',
|
||||
recoveryEmail: '[data-testid="proof-confirmation"]',
|
||||
passwordIcon: '[data-testid="tile"]:has(svg path[d*="M11.78 10.22a.75.75"])',
|
||||
accountLocked: '#serviceAbuseLandingTitle',
|
||||
errorAlert: 'div[role="alert"]',
|
||||
passwordEntry: '[data-testid="passwordEntry"]',
|
||||
emailEntry: 'input#usernameEntry',
|
||||
kmsiVideo: '[data-testid="kmsiVideo"]',
|
||||
passKeyVideo: '[data-testid="biometricVideo"]',
|
||||
passKeyError: '[data-testid="registrationImg"]',
|
||||
passwordlessCheck: '[data-testid="deviceShieldCheckmarkVideo"]',
|
||||
totpInput: 'input[name="otc"]',
|
||||
totpInputOld: 'form[name="OneTimeCodeViewForm"]',
|
||||
identityBanner: '[data-testid="identityBanner"]',
|
||||
viewFooter: '[data-testid="viewFooter"] span[role="button"] >> nth=-1',
|
||||
bingProfile: '#id_n',
|
||||
requestToken: 'input[name="__RequestVerificationToken"]',
|
||||
requestTokenMeta: 'meta[name="__RequestVerificationToken"]'
|
||||
} as const
|
||||
|
||||
constructor(private bot: MicrosoftRewardsBot) {
|
||||
this.emailLogin = new EmailLogin(this.bot)
|
||||
this.passwordlessLogin = new PasswordlessLogin(this.bot)
|
||||
this.totp2FALogin = new TotpLogin(this.bot)
|
||||
this.codeLogin = new CodeLogin(this.bot)
|
||||
this.recoveryLogin = new RecoveryLogin(this.bot)
|
||||
}
|
||||
|
||||
async login(page: Page, account: Account) {
|
||||
try {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting login process')
|
||||
|
||||
await page.goto('https://www.bing.com/rewards/dashboard', { waitUntil: 'domcontentloaded' }).catch(() => {})
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.bot.browser.utils.reloadBadPage(page)
|
||||
await this.bot.browser.utils.disableFido(page)
|
||||
|
||||
const maxIterations = 25
|
||||
let iteration = 0
|
||||
let previousState: LoginState = 'UNKNOWN'
|
||||
let sameStateCount = 0
|
||||
|
||||
while (iteration < maxIterations) {
|
||||
if (page.isClosed()) throw new Error('Page closed unexpectedly')
|
||||
|
||||
iteration++
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `State check iteration ${iteration}/${maxIterations}`)
|
||||
|
||||
const state = await this.detectCurrentState(page, account)
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `Current state: ${state}`)
|
||||
|
||||
if (state !== previousState && previousState !== 'UNKNOWN') {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', `State transition: ${previousState} → ${state}`)
|
||||
}
|
||||
|
||||
if (state === previousState && state !== 'LOGGED_IN' && state !== 'UNKNOWN') {
|
||||
sameStateCount++
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
`Same state count: ${sameStateCount}/4 for state "${state}"`
|
||||
)
|
||||
if (sameStateCount >= 4) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
`Stuck in state "${state}" for 4 loops, refreshing page`
|
||||
)
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await this.bot.utils.wait(3000)
|
||||
sameStateCount = 0
|
||||
previousState = 'UNKNOWN'
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
sameStateCount = 0
|
||||
}
|
||||
previousState = state
|
||||
|
||||
if (state === 'LOGGED_IN') {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Successfully logged in')
|
||||
break
|
||||
}
|
||||
|
||||
const shouldContinue = await this.handleState(state, page, account)
|
||||
if (!shouldContinue) {
|
||||
throw new Error(`Login failed or aborted at state: ${state}`)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
|
||||
if (iteration >= maxIterations) {
|
||||
throw new Error('Login timeout: exceeded maximum iterations')
|
||||
}
|
||||
|
||||
await this.finalizeLogin(page, account.email)
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async detectCurrentState(page: Page, account?: Account): Promise<LoginState> {
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
const url = new URL(page.url())
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Current URL: ${url.hostname}${url.pathname}`)
|
||||
|
||||
if (url.hostname === 'chromewebdata') {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'DETECT-STATE', 'Detected chromewebdata error page')
|
||||
return 'CHROMEWEBDATA_ERROR'
|
||||
}
|
||||
|
||||
const isLocked = await this.checkSelector(page, this.selectors.accountLocked)
|
||||
if (isLocked) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'Account locked selector found')
|
||||
return 'ACCOUNT_LOCKED'
|
||||
}
|
||||
|
||||
if (url.hostname === 'rewards.bing.com' || url.hostname === 'account.microsoft.com') {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'On rewards/account page, assuming logged in')
|
||||
return 'LOGGED_IN'
|
||||
}
|
||||
|
||||
const stateChecks: Array<[string, LoginState]> = [
|
||||
[this.selectors.errorAlert, 'ERROR_ALERT'],
|
||||
[this.selectors.passwordEntry, 'PASSWORD_INPUT'],
|
||||
[this.selectors.emailEntry, 'EMAIL_INPUT'],
|
||||
[this.selectors.recoveryEmail, 'RECOVERY_EMAIL_INPUT'],
|
||||
[this.selectors.kmsiVideo, 'KMSI_PROMPT'],
|
||||
[this.selectors.passKeyVideo, 'PASSKEY_VIDEO'],
|
||||
[this.selectors.passKeyError, 'PASSKEY_ERROR'],
|
||||
[this.selectors.passwordIcon, 'SIGN_IN_ANOTHER_WAY'],
|
||||
[this.selectors.emailIcon, 'SIGN_IN_ANOTHER_WAY_EMAIL'],
|
||||
[this.selectors.emailIconOld, 'SIGN_IN_ANOTHER_WAY_EMAIL'],
|
||||
[this.selectors.passwordlessCheck, 'LOGIN_PASSWORDLESS'],
|
||||
[this.selectors.totpInput, '2FA_TOTP'],
|
||||
[this.selectors.totpInputOld, '2FA_TOTP']
|
||||
]
|
||||
|
||||
const results = await Promise.all(
|
||||
stateChecks.map(async ([sel, state]) => {
|
||||
const visible = await this.checkSelector(page, sel)
|
||||
return visible ? state : null
|
||||
})
|
||||
)
|
||||
|
||||
const visibleStates = results.filter((s): s is LoginState => s !== null)
|
||||
if (visibleStates.length > 0) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Visible states: [${visibleStates.join(', ')}]`)
|
||||
}
|
||||
|
||||
const [identityBanner, primaryButton, passwordEntry] = await Promise.all([
|
||||
this.checkSelector(page, this.selectors.identityBanner),
|
||||
this.checkSelector(page, this.selectors.primaryButton),
|
||||
this.checkSelector(page, this.selectors.passwordEntry)
|
||||
])
|
||||
|
||||
if (identityBanner && primaryButton && !passwordEntry && !results.includes('2FA_TOTP')) {
|
||||
const codeState = account?.password ? 'GET_A_CODE' : 'GET_A_CODE_2'
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DETECT-STATE',
|
||||
`Get code state detected: ${codeState} (has password: ${!!account?.password})`
|
||||
)
|
||||
results.push(codeState)
|
||||
}
|
||||
|
||||
let foundStates = results.filter((s): s is LoginState => s !== null)
|
||||
|
||||
if (foundStates.length === 0) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', 'No matching states found')
|
||||
return 'UNKNOWN'
|
||||
}
|
||||
|
||||
if (foundStates.includes('ERROR_ALERT')) {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DETECT-STATE',
|
||||
`ERROR_ALERT found - hostname: ${url.hostname}, has 2FA: ${foundStates.includes('2FA_TOTP')}`
|
||||
)
|
||||
if (url.hostname !== 'login.live.com') {
|
||||
foundStates = foundStates.filter(s => s !== 'ERROR_ALERT')
|
||||
}
|
||||
if (foundStates.includes('2FA_TOTP')) {
|
||||
foundStates = foundStates.filter(s => s !== 'ERROR_ALERT')
|
||||
}
|
||||
if (foundStates.includes('ERROR_ALERT')) return 'ERROR_ALERT'
|
||||
}
|
||||
|
||||
const priorities: LoginState[] = [
|
||||
'ACCOUNT_LOCKED',
|
||||
'PASSKEY_VIDEO',
|
||||
'PASSKEY_ERROR',
|
||||
'KMSI_PROMPT',
|
||||
'PASSWORD_INPUT',
|
||||
'EMAIL_INPUT',
|
||||
'SIGN_IN_ANOTHER_WAY_EMAIL',
|
||||
'SIGN_IN_ANOTHER_WAY',
|
||||
'LOGIN_PASSWORDLESS',
|
||||
'2FA_TOTP'
|
||||
]
|
||||
|
||||
for (const priority of priorities) {
|
||||
if (foundStates.includes(priority)) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Selected state by priority: ${priority}`)
|
||||
return priority
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DETECT-STATE', `Returning first found state: ${foundStates[0]}`)
|
||||
return foundStates[0] as LoginState
|
||||
}
|
||||
|
||||
private async checkSelector(page: Page, selector: string): Promise<boolean> {
|
||||
return page
|
||||
.waitForSelector(selector, { state: 'visible', timeout: 200 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
}
|
||||
|
||||
private async handleState(state: LoginState, page: Page, account: Account): Promise<boolean> {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `Processing state: ${state}`)
|
||||
|
||||
switch (state) {
|
||||
case 'ACCOUNT_LOCKED': {
|
||||
const msg = 'This account has been locked! Remove from config and restart!'
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN', msg)
|
||||
throw new Error(msg)
|
||||
}
|
||||
|
||||
case 'ERROR_ALERT': {
|
||||
const alertEl = page.locator(this.selectors.errorAlert)
|
||||
const errorMsg = await alertEl.innerText().catch(() => 'Unknown Error')
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN', `Account error: ${errorMsg}`)
|
||||
throw new Error(`Microsoft login error: ${errorMsg}`)
|
||||
}
|
||||
|
||||
case 'LOGGED_IN':
|
||||
return true
|
||||
|
||||
case 'EMAIL_INPUT': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Entering email')
|
||||
await this.emailLogin.enterEmail(page, account.email)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after email entry')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Email entered successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'PASSWORD_INPUT': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Entering password')
|
||||
await this.emailLogin.enterPassword(page, account.password)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after password entry')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Password entered successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'GET_A_CODE': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Attempting to bypass "Get code" via footer')
|
||||
await this.bot.browser.utils.ghostClick(page, this.selectors.viewFooter)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after footer click')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Footer clicked, proceeding')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'GET_A_CODE_2': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Handling "Get a code" flow')
|
||||
await this.bot.browser.utils.ghostClick(page, this.selectors.primaryButton)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after primary button click')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Initiating code login handler')
|
||||
await this.codeLogin.handle(page)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Code login handler completed successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'SIGN_IN_ANOTHER_WAY_EMAIL': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting "Send a code to email"')
|
||||
|
||||
const emailSelector = await Promise.race([
|
||||
this.checkSelector(page, this.selectors.emailIcon).then(found =>
|
||||
found ? this.selectors.emailIcon : null
|
||||
),
|
||||
this.checkSelector(page, this.selectors.emailIconOld).then(found =>
|
||||
found ? this.selectors.emailIconOld : null
|
||||
)
|
||||
])
|
||||
|
||||
if (!emailSelector) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Email icon not found')
|
||||
return false
|
||||
}
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
`Using ${emailSelector === this.selectors.emailIcon ? 'new' : 'old'} email icon selector`
|
||||
)
|
||||
await this.bot.browser.utils.ghostClick(page, emailSelector)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after email icon click')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Initiating code login handler')
|
||||
await this.codeLogin.handle(page)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Code login handler completed successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'RECOVERY_EMAIL_INPUT': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery email input detected')
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout on recovery page')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Initiating recovery email handler')
|
||||
await this.recoveryLogin.handle(page, account?.recoveryEmail)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery email handler completed successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'CHROMEWEBDATA_ERROR': {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'chromewebdata error detected, attempting recovery')
|
||||
try {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', `Navigating to ${this.bot.config.baseURL}`)
|
||||
await page
|
||||
.goto(this.bot.config.baseURL, {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 10000
|
||||
})
|
||||
.catch(() => {})
|
||||
await this.bot.utils.wait(3000)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Recovery navigation successful')
|
||||
return true
|
||||
} catch {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Fallback to login.live.com')
|
||||
await page
|
||||
.goto('https://login.live.com/', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 10000
|
||||
})
|
||||
.catch(() => {})
|
||||
await this.bot.utils.wait(3000)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Fallback navigation successful')
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
case '2FA_TOTP': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA authentication required')
|
||||
await this.totp2FALogin.handle(page, account.totpSecret)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'TOTP 2FA handler completed successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'SIGN_IN_ANOTHER_WAY': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting "Use my password"')
|
||||
await this.bot.browser.utils.ghostClick(page, this.selectors.passwordIcon)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after password icon click')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Password option selected')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'KMSI_PROMPT': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Accepting KMSI prompt')
|
||||
await this.bot.browser.utils.ghostClick(page, this.selectors.primaryButton)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after KMSI acceptance')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'KMSI prompt accepted')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'PASSKEY_VIDEO':
|
||||
case 'PASSKEY_ERROR': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Skipping Passkey prompt')
|
||||
await this.bot.browser.utils.ghostClick(page, this.selectors.secondaryButton)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after Passkey skip')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Passkey prompt skipped')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'LOGIN_PASSWORDLESS': {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Handling passwordless authentication')
|
||||
await this.passwordlessLogin.handle(page)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', 'Network idle timeout after passwordless auth')
|
||||
})
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Passwordless authentication completed successfully')
|
||||
return true
|
||||
}
|
||||
|
||||
case 'UNKNOWN': {
|
||||
const url = new URL(page.url())
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN',
|
||||
`Unknown state at ${url.hostname}${url.pathname}, waiting`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
default:
|
||||
this.bot.logger.debug(this.bot.isMobile, 'HANDLE-STATE', `Unhandled state: ${state}, continuing`)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private async finalizeLogin(page: Page, email: string) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Finalizing login')
|
||||
|
||||
await page.goto(this.bot.config.baseURL, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {})
|
||||
|
||||
const loginRewardsSuccess = new URL(page.url()).hostname === 'rewards.bing.com'
|
||||
if (loginRewardsSuccess) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Logged into Microsoft Rewards successfully')
|
||||
} else {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN', 'Could not verify Rewards Dashboard, assuming login valid')
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting Bing session verification')
|
||||
await this.verifyBingSession(page)
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Starting rewards session verification')
|
||||
await this.getRewardsSession(page)
|
||||
|
||||
const browser = page.context()
|
||||
const cookies = await browser.cookies()
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN', `Retrieved ${cookies.length} cookies`)
|
||||
await saveSessionData(this.bot.config.sessionPath, cookies, email, this.bot.isMobile)
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Login completed, session saved')
|
||||
}
|
||||
|
||||
async verifyBingSession(page: Page) {
|
||||
const url =
|
||||
'https://www.bing.com/fd/auth/signin?action=interactive&provider=windows_live_id&return_url=https%3A%2F%2Fwww.bing.com%2F'
|
||||
const loopMax = 5
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Verifying Bing session')
|
||||
|
||||
try {
|
||||
await page.goto(url, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {})
|
||||
|
||||
for (let i = 0; i < loopMax; i++) {
|
||||
if (page.isClosed()) break
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-BING', `Verification loop ${i + 1}/${loopMax}`)
|
||||
|
||||
const state = await this.detectCurrentState(page)
|
||||
if (state === 'PASSKEY_ERROR') {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Dismissing Passkey error state')
|
||||
await this.bot.browser.utils.ghostClick(page, this.selectors.secondaryButton)
|
||||
}
|
||||
|
||||
const u = new URL(page.url())
|
||||
const atBingHome = u.hostname === 'www.bing.com' && u.pathname === '/'
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-BING',
|
||||
`At Bing home: ${atBingHome} (${u.hostname}${u.pathname})`
|
||||
)
|
||||
|
||||
if (atBingHome) {
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page).catch(() => {})
|
||||
|
||||
const signedIn = await page
|
||||
.waitForSelector(this.selectors.bingProfile, { timeout: 3000 })
|
||||
.then(() => true)
|
||||
.catch(() => false)
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-BING', `Profile element found: ${signedIn}`)
|
||||
|
||||
if (signedIn || this.bot.isMobile) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-BING', 'Bing session verified successfully')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-BING', 'Could not verify Bing session, continuing anyway')
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-BING',
|
||||
`Verification error: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async getRewardsSession(page: Page) {
|
||||
const loopMax = 5
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'GET-REWARD-SESSION', 'Fetching request token')
|
||||
|
||||
try {
|
||||
await page
|
||||
.goto(`${this.bot.config.baseURL}?_=${Date.now()}`, { waitUntil: 'networkidle', timeout: 10000 })
|
||||
.catch(() => {})
|
||||
|
||||
for (let i = 0; i < loopMax; i++) {
|
||||
if (page.isClosed()) break
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'GET-REWARD-SESSION', `Token fetch loop ${i + 1}/${loopMax}`)
|
||||
|
||||
const u = new URL(page.url())
|
||||
const atRewardHome = u.hostname === 'rewards.bing.com' && u.pathname === '/'
|
||||
|
||||
if (atRewardHome) {
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
|
||||
const html = await page.content()
|
||||
const $ = await this.bot.browser.utils.loadInCheerio(html)
|
||||
|
||||
const token =
|
||||
$(this.selectors.requestToken).attr('value') ??
|
||||
$(this.selectors.requestTokenMeta).attr('content') ??
|
||||
null
|
||||
|
||||
if (token) {
|
||||
this.bot.requestToken = token
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'GET-REWARD-SESSION',
|
||||
`Request token retrieved: ${token.substring(0, 10)}...`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'GET-REWARD-SESSION', 'Token not found on page')
|
||||
} else {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'GET-REWARD-SESSION',
|
||||
`Not at reward home: ${u.hostname}${u.pathname}`
|
||||
)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'GET-REWARD-SESSION',
|
||||
'No RequestVerificationToken found, some activities may not work'
|
||||
)
|
||||
} catch (error) {
|
||||
throw this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'GET-REWARD-SESSION',
|
||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async getAppAccessToken(page: Page, email: string) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'GET-APP-TOKEN', 'Requesting mobile access token')
|
||||
return await new MobileAccessLogin(this.bot, page).get(email)
|
||||
}
|
||||
}
|
||||
86
src/browser/auth/methods/EmailLogin.ts
Normal file
86
src/browser/auth/methods/EmailLogin.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Page } from 'patchright'
|
||||
import type { MicrosoftRewardsBot } from '../../../index'
|
||||
|
||||
export class EmailLogin {
|
||||
private submitButton = 'button[type="submit"]'
|
||||
|
||||
constructor(private bot: MicrosoftRewardsBot) {}
|
||||
|
||||
async enterEmail(page: Page, email: string): Promise<'ok' | 'error'> {
|
||||
try {
|
||||
const emailInputSelector = 'input[type="email"]'
|
||||
const emailField = await page
|
||||
.waitForSelector(emailInputSelector, { state: 'visible', timeout: 1000 })
|
||||
.catch(() => {})
|
||||
if (!emailField) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email field not found')
|
||||
return 'error'
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
|
||||
const prefilledEmail = await page
|
||||
.waitForSelector('#userDisplayName', { state: 'visible', timeout: 1000 })
|
||||
.catch(() => {})
|
||||
if (!prefilledEmail) {
|
||||
await page.fill(emailInputSelector, '').catch(() => {})
|
||||
await this.bot.utils.wait(500)
|
||||
await page.fill(emailInputSelector, email).catch(() => {})
|
||||
await this.bot.utils.wait(1000)
|
||||
} else {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email prefilled')
|
||||
}
|
||||
|
||||
await page.waitForSelector(this.submitButton, { state: 'visible', timeout: 2000 }).catch(() => {})
|
||||
|
||||
await this.bot.browser.utils.ghostClick(page, this.submitButton)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-EMAIL', 'Email submitted')
|
||||
|
||||
return 'ok'
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-ENTER-EMAIL',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async enterPassword(page: Page, password: string): Promise<'ok' | 'needs-2fa' | 'error'> {
|
||||
try {
|
||||
const passwordInputSelector = 'input[type="password"]'
|
||||
const passwordField = await page
|
||||
.waitForSelector(passwordInputSelector, { state: 'visible', timeout: 1000 })
|
||||
.catch(() => {})
|
||||
if (!passwordField) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Password field not found')
|
||||
return 'error'
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
await page.fill(passwordInputSelector, '').catch(() => {})
|
||||
await this.bot.utils.wait(500)
|
||||
await page.fill(passwordInputSelector, password).catch(() => {})
|
||||
await this.bot.utils.wait(1000)
|
||||
|
||||
const submitButton = await page
|
||||
.waitForSelector(this.submitButton, { state: 'visible', timeout: 2000 })
|
||||
.catch(() => null)
|
||||
|
||||
if (submitButton) {
|
||||
await this.bot.browser.utils.ghostClick(page, this.submitButton)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-ENTER-PASSWORD', 'Password submitted')
|
||||
}
|
||||
|
||||
return 'ok'
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-ENTER-PASSWORD',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return 'error'
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/browser/auth/methods/GetACodeLogin.ts
Normal file
129
src/browser/auth/methods/GetACodeLogin.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { Page } from 'patchright'
|
||||
import type { MicrosoftRewardsBot } from '../../../index'
|
||||
import { getErrorMessage, getSubtitleMessage, promptInput } from './LoginUtils'
|
||||
|
||||
export class CodeLogin {
|
||||
private readonly textInputSelector = '[data-testid="codeInputWrapper"]'
|
||||
private readonly secondairyInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]'
|
||||
private readonly maxManualSeconds = 60
|
||||
private readonly maxManualAttempts = 5
|
||||
|
||||
constructor(private bot: MicrosoftRewardsBot) {}
|
||||
|
||||
private async fillCode(page: Page, code: string): Promise<boolean> {
|
||||
try {
|
||||
const visibleInput = await page
|
||||
.waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 })
|
||||
.catch(() => null)
|
||||
|
||||
if (visibleInput) {
|
||||
await page.keyboard.type(code, { delay: 50 })
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Filled code input')
|
||||
return true
|
||||
}
|
||||
|
||||
const secondairyInput = await page.$(this.secondairyInputSelector)
|
||||
if (secondairyInput) {
|
||||
await page.keyboard.type(code, { delay: 50 })
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Filled code input')
|
||||
return true
|
||||
}
|
||||
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'No code input field found')
|
||||
return false
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-CODE',
|
||||
`Failed to fill code input: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async handle(page: Page): Promise<void> {
|
||||
try {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Code login authentication requested')
|
||||
|
||||
const emailMessage = await getSubtitleMessage(page)
|
||||
if (emailMessage) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', `Page message: "${emailMessage}"`)
|
||||
} else {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-CODE', 'Unable to retrieve email code destination')
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) {
|
||||
const code = await promptInput({
|
||||
question: `Enter the 6-digit code (waiting ${this.maxManualSeconds}s): `,
|
||||
timeoutSeconds: this.maxManualSeconds,
|
||||
validate: code => /^\d{6}$/.test(code)
|
||||
})
|
||||
|
||||
if (!code || !/^\d{6}$/.test(code)) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-CODE',
|
||||
`Invalid or missing code (attempt ${attempt}/${this.maxManualAttempts}) | input length=${code?.length}`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Manual code input failed or timed out')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const filled = await this.fillCode(page, code)
|
||||
if (!filled) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-CODE',
|
||||
`Unable to fill code input (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Code input field not found')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(500)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
// Check if wrong code was entered
|
||||
const errorMessage = await getErrorMessage(page)
|
||||
if (errorMessage) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-CODE',
|
||||
`Incorrect code: ${errorMessage} (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error(`Maximum attempts reached: ${errorMessage}`)
|
||||
}
|
||||
|
||||
// Clear the input field before retrying
|
||||
const inputToClear = await page.$(this.textInputSelector).catch(() => null)
|
||||
if (inputToClear) {
|
||||
await inputToClear.click()
|
||||
await page.keyboard.press('Control+A')
|
||||
await page.keyboard.press('Backspace')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-CODE', 'Code authentication completed successfully')
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Code input failed after ${this.maxManualAttempts} attempts`)
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-CODE',
|
||||
`Error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/browser/auth/methods/LoginUtils.ts
Normal file
66
src/browser/auth/methods/LoginUtils.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { Page } from 'patchright'
|
||||
import readline from 'readline'
|
||||
|
||||
export interface PromptOptions {
|
||||
question: string
|
||||
timeoutSeconds?: number
|
||||
validate?: (input: string) => boolean
|
||||
transform?: (input: string) => string
|
||||
}
|
||||
|
||||
export function promptInput(options: PromptOptions): Promise<string | null> {
|
||||
const { question, timeoutSeconds = 60, validate, transform } = options
|
||||
|
||||
return new Promise(resolve => {
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
let resolved = false
|
||||
|
||||
const cleanup = (result: string | null) => {
|
||||
if (resolved) return
|
||||
resolved = true
|
||||
clearTimeout(timer)
|
||||
rl.close()
|
||||
resolve(result)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => cleanup(null), timeoutSeconds * 1000)
|
||||
|
||||
rl.question(question, answer => {
|
||||
let value = answer.trim()
|
||||
if (transform) value = transform(value)
|
||||
|
||||
if (validate && !validate(value)) {
|
||||
cleanup(null)
|
||||
return
|
||||
}
|
||||
|
||||
cleanup(value)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function getSubtitleMessage(page: Page): Promise<string | null> {
|
||||
const message = await page
|
||||
.waitForSelector('[data-testid="subtitle"]', { state: 'visible', timeout: 1000 })
|
||||
.catch(() => null)
|
||||
|
||||
if (!message) return null
|
||||
|
||||
const text = await message.innerText()
|
||||
return text.trim()
|
||||
}
|
||||
|
||||
export async function getErrorMessage(page: Page): Promise<string | null> {
|
||||
const errorAlert = await page
|
||||
.waitForSelector('div[role="alert"]', { state: 'visible', timeout: 1000 })
|
||||
.catch(() => null)
|
||||
|
||||
if (!errorAlert) return null
|
||||
|
||||
const text = await errorAlert.innerText()
|
||||
return text.trim()
|
||||
}
|
||||
139
src/browser/auth/methods/MobileAccessLogin.ts
Normal file
139
src/browser/auth/methods/MobileAccessLogin.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import type { Page } from 'patchright'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { URLSearchParams } from 'url'
|
||||
|
||||
import type { MicrosoftRewardsBot } from '../../../index'
|
||||
|
||||
export class MobileAccessLogin {
|
||||
private clientId = '0000000040170455'
|
||||
private authUrl = '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'
|
||||
private maxTimeout = 180_000 // 3min
|
||||
|
||||
constructor(
|
||||
private bot: MicrosoftRewardsBot,
|
||||
private page: Page
|
||||
) {}
|
||||
|
||||
async get(email: string): Promise<string> {
|
||||
try {
|
||||
const authorizeUrl = new URL(this.authUrl)
|
||||
authorizeUrl.searchParams.append('response_type', 'code')
|
||||
authorizeUrl.searchParams.append('client_id', this.clientId)
|
||||
authorizeUrl.searchParams.append('redirect_uri', this.redirectUrl)
|
||||
authorizeUrl.searchParams.append('scope', this.scope)
|
||||
authorizeUrl.searchParams.append('state', randomBytes(16).toString('hex'))
|
||||
authorizeUrl.searchParams.append('access_type', 'offline_access')
|
||||
authorizeUrl.searchParams.append('login_hint', email)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`Auth URL constructed: ${authorizeUrl.origin}${authorizeUrl.pathname}`
|
||||
)
|
||||
|
||||
await this.bot.browser.utils.disableFido(this.page)
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Navigating to OAuth authorize URL')
|
||||
|
||||
await this.page.goto(authorizeUrl.href).catch(err => {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`page.goto() failed: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
})
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Waiting for mobile OAuth code...')
|
||||
|
||||
const start = Date.now()
|
||||
let code = ''
|
||||
let lastUrl = ''
|
||||
|
||||
while (Date.now() - start < this.maxTimeout) {
|
||||
const currentUrl = this.page.url()
|
||||
|
||||
// Log only when URL changes (high signal, no spam)
|
||||
if (currentUrl !== lastUrl) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', `OAuth poll URL changed → ${currentUrl}`)
|
||||
lastUrl = currentUrl
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(currentUrl)
|
||||
|
||||
if (url.hostname === 'login.live.com' && url.pathname === '/oauth20_desktop.srf') {
|
||||
code = url.searchParams.get('code') || ''
|
||||
|
||||
if (code) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'OAuth code detected in redirect URL')
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`Invalid URL while polling: ${String(currentUrl)}`
|
||||
)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`Timed out waiting for OAuth code after ${Math.round((Date.now() - start) / 1000)}s`
|
||||
)
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', `Final page URL: ${this.page.url()}`)
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const data = new URLSearchParams()
|
||||
data.append('grant_type', 'authorization_code')
|
||||
data.append('client_id', this.clientId)
|
||||
data.append('code', code)
|
||||
data.append('redirect_uri', this.redirectUrl)
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Exchanging OAuth code for access token')
|
||||
|
||||
const response = await this.bot.axios.request({
|
||||
url: this.tokenUrl,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
data: data.toString()
|
||||
})
|
||||
|
||||
const token = (response?.data?.access_token as string) ?? ''
|
||||
|
||||
if (!token) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-APP', 'No access_token in token response')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`Token response payload: ${JSON.stringify(response?.data)}`
|
||||
)
|
||||
return ''
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-APP', 'Mobile access token received')
|
||||
return token
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-APP',
|
||||
`MobileAccess error: ${error instanceof Error ? error.stack || error.message : String(error)}`
|
||||
)
|
||||
return ''
|
||||
} finally {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-APP', 'Returning to base URL')
|
||||
await this.page.goto(this.bot.config.baseURL, { timeout: 10000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
}
|
||||
110
src/browser/auth/methods/PasswordlessLogin.ts
Normal file
110
src/browser/auth/methods/PasswordlessLogin.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Page } from 'patchright'
|
||||
import type { MicrosoftRewardsBot } from '../../../index'
|
||||
|
||||
export class PasswordlessLogin {
|
||||
private readonly maxAttempts = 60
|
||||
private readonly numberDisplaySelector = 'div[data-testid="displaySign"]'
|
||||
private readonly approvalPath = '/ppsecure/post.srf'
|
||||
|
||||
constructor(private bot: MicrosoftRewardsBot) {}
|
||||
|
||||
private async getDisplayedNumber(page: Page): Promise<string | null> {
|
||||
try {
|
||||
const numberElement = await page
|
||||
.waitForSelector(this.numberDisplaySelector, {
|
||||
timeout: 5000
|
||||
})
|
||||
.catch(() => null)
|
||||
|
||||
if (numberElement) {
|
||||
const number = await numberElement.textContent()
|
||||
return number?.trim() || null
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Could not retrieve displayed number')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private async waitForApproval(page: Page): Promise<boolean> {
|
||||
try {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-PASSWORDLESS',
|
||||
`Waiting for approval... (timeout after ${this.maxAttempts} seconds)`
|
||||
)
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxAttempts; attempt++) {
|
||||
const currentUrl = new URL(page.url())
|
||||
if (currentUrl.pathname === this.approvalPath) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Approval detected')
|
||||
return true
|
||||
}
|
||||
|
||||
// Every 5 seconds to show it's still waiting
|
||||
if (attempt % 5 === 0) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-PASSWORDLESS',
|
||||
`Still waiting... (${attempt}/${this.maxAttempts} seconds elapsed)`
|
||||
)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-PASSWORDLESS',
|
||||
`Approval timeout after ${this.maxAttempts} seconds!`
|
||||
)
|
||||
return false
|
||||
} catch (error: any) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-PASSWORDLESS',
|
||||
`Approval failed, an error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async handle(page: Page): Promise<void> {
|
||||
try {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Passwordless authentication requested')
|
||||
|
||||
const displayedNumber = await this.getDisplayedNumber(page)
|
||||
|
||||
if (displayedNumber) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-PASSWORDLESS',
|
||||
`Please approve login and select number: ${displayedNumber}`
|
||||
)
|
||||
} else {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-PASSWORDLESS',
|
||||
'Please approve login on your authenticator app'
|
||||
)
|
||||
}
|
||||
|
||||
const approved = await this.waitForApproval(page)
|
||||
|
||||
if (approved) {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Login approved successfully')
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
} else {
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN-PASSWORDLESS', 'Login approval failed or timed out')
|
||||
throw new Error('Passwordless authentication timeout')
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-PASSWORDLESS',
|
||||
`An error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
187
src/browser/auth/methods/RecoveryEmailLogin.ts
Normal file
187
src/browser/auth/methods/RecoveryEmailLogin.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import type { Page } from 'patchright'
|
||||
import type { MicrosoftRewardsBot } from '../../../index'
|
||||
import { getErrorMessage, promptInput } from './LoginUtils'
|
||||
|
||||
export class RecoveryLogin {
|
||||
private readonly textInputSelector = '[data-testid="proof-confirmation"]'
|
||||
private readonly maxManualSeconds = 60
|
||||
private readonly maxManualAttempts = 5
|
||||
|
||||
constructor(private bot: MicrosoftRewardsBot) {}
|
||||
|
||||
private async fillEmail(page: Page, email: string): Promise<boolean> {
|
||||
try {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', `Attempting to fill email: ${email}`)
|
||||
|
||||
const visibleInput = await page
|
||||
.waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 })
|
||||
.catch(() => null)
|
||||
|
||||
if (visibleInput) {
|
||||
await page.keyboard.type(email, { delay: 50 })
|
||||
await page.keyboard.press('Enter')
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Successfully filled email input field')
|
||||
return true
|
||||
}
|
||||
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Email input field not found with selector: ${this.textInputSelector}`
|
||||
)
|
||||
return false
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Failed to fill email input: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async handle(page: Page, recoveryEmail: string): Promise<void> {
|
||||
try {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email recovery authentication flow initiated')
|
||||
|
||||
if (recoveryEmail) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Using provided recovery email: ${recoveryEmail}`
|
||||
)
|
||||
|
||||
const filled = await this.fillEmail(page, recoveryEmail)
|
||||
if (!filled) {
|
||||
throw new Error('Email input field not found')
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Waiting for page response')
|
||||
await this.bot.utils.wait(500)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', 'Network idle timeout reached')
|
||||
})
|
||||
|
||||
const errorMessage = await getErrorMessage(page)
|
||||
if (errorMessage) {
|
||||
throw new Error(`Email verification failed: ${errorMessage}`)
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email authentication completed successfully')
|
||||
return
|
||||
}
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
'No recovery email provided, will prompt user for input'
|
||||
)
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Starting attempt ${attempt}/${this.maxManualAttempts}`
|
||||
)
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Prompting user for email input (timeout: ${this.maxManualSeconds}s)`
|
||||
)
|
||||
|
||||
const email = await promptInput({
|
||||
question: `Recovery email (waiting ${this.maxManualSeconds}s): `,
|
||||
timeoutSeconds: this.maxManualSeconds,
|
||||
validate: email => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
})
|
||||
|
||||
if (!email) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`No or invalid email input received (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Manual email input failed: no input received')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Invalid email format received (attempt ${attempt}/${this.maxManualAttempts}) | length=${email.length}`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Manual email input failed: invalid format')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', `Valid email received from user: ${email}`)
|
||||
|
||||
const filled = await this.fillEmail(page, email)
|
||||
if (!filled) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Failed to fill email input field (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Email input field not found after maximum attempts')
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Waiting for page response')
|
||||
await this.bot.utils.wait(500)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'LOGIN-RECOVERY', 'Network idle timeout reached')
|
||||
})
|
||||
|
||||
const errorMessage = await getErrorMessage(page)
|
||||
if (errorMessage) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-RECOVERY',
|
||||
`Error from page: "${errorMessage}" (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error(`Maximum attempts reached. Last error: ${errorMessage}`)
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Clearing input field for retry')
|
||||
const inputToClear = await page.$(this.textInputSelector).catch(() => null)
|
||||
if (inputToClear) {
|
||||
await inputToClear.click()
|
||||
await page.keyboard.press('Control+A')
|
||||
await page.keyboard.press('Backspace')
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Input field cleared')
|
||||
} else {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-RECOVERY', 'Could not find input field to clear')
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-RECOVERY', 'Email authentication completed successfully')
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`Email input failed after ${this.maxManualAttempts} attempts`)
|
||||
} catch (error) {
|
||||
const errorMsg = error instanceof Error ? error.message : String(error)
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN-RECOVERY', `Fatal error: ${errorMsg}`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
148
src/browser/auth/methods/Totp2FALogin.ts
Normal file
148
src/browser/auth/methods/Totp2FALogin.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import type { Page } from 'patchright'
|
||||
import * as OTPAuth from 'otpauth'
|
||||
import type { MicrosoftRewardsBot } from '../../../index'
|
||||
import { getErrorMessage, promptInput } from './LoginUtils'
|
||||
|
||||
export class TotpLogin {
|
||||
private readonly textInputSelector =
|
||||
'form[name="OneTimeCodeViewForm"] input[type="text"], input#floatingLabelInput5'
|
||||
private readonly secondairyInputSelector = 'input[id="otc-confirmation-input"], input[name="otc"]'
|
||||
private readonly submitButtonSelector = 'button[type="submit"]'
|
||||
private readonly maxManualSeconds = 60
|
||||
private readonly maxManualAttempts = 5
|
||||
|
||||
constructor(private bot: MicrosoftRewardsBot) {}
|
||||
|
||||
private generateTotpCode(secret: string): string {
|
||||
return new OTPAuth.TOTP({ secret, digits: 6 }).generate()
|
||||
}
|
||||
|
||||
private async fillCode(page: Page, code: string): Promise<boolean> {
|
||||
try {
|
||||
const visibleInput = await page
|
||||
.waitForSelector(this.textInputSelector, { state: 'visible', timeout: 500 })
|
||||
.catch(() => null)
|
||||
|
||||
if (visibleInput) {
|
||||
await visibleInput.fill(code)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled TOTP input')
|
||||
return true
|
||||
}
|
||||
|
||||
const secondairyInput = await page.$(this.secondairyInputSelector)
|
||||
if (secondairyInput) {
|
||||
await secondairyInput.fill(code)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Filled TOTP input')
|
||||
return true
|
||||
}
|
||||
|
||||
this.bot.logger.warn(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP input field found')
|
||||
return false
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-TOTP',
|
||||
`Failed to fill TOTP input: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async handle(page: Page, totpSecret?: string): Promise<void> {
|
||||
try {
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP 2FA authentication requested')
|
||||
|
||||
if (totpSecret) {
|
||||
const code = this.generateTotpCode(totpSecret)
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'Generated TOTP code from secret')
|
||||
|
||||
const filled = await this.fillCode(page, code)
|
||||
if (!filled) {
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', 'Unable to fill TOTP input field')
|
||||
throw new Error('TOTP input field not found')
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(500)
|
||||
await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
const errorMessage = await getErrorMessage(page)
|
||||
if (errorMessage) {
|
||||
this.bot.logger.error(this.bot.isMobile, 'LOGIN-TOTP', `TOTP failed: ${errorMessage}`)
|
||||
throw new Error(`TOTP authentication failed: ${errorMessage}`)
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully')
|
||||
return
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'No TOTP secret provided, awaiting manual input')
|
||||
|
||||
for (let attempt = 1; attempt <= this.maxManualAttempts; attempt++) {
|
||||
const code = await promptInput({
|
||||
question: `Enter the 6-digit TOTP code (waiting ${this.maxManualSeconds}s): `,
|
||||
timeoutSeconds: this.maxManualSeconds,
|
||||
validate: code => /^\d{6}$/.test(code)
|
||||
})
|
||||
|
||||
if (!code || !/^\d{6}$/.test(code)) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-TOTP',
|
||||
`Invalid or missing code (attempt ${attempt}/${this.maxManualAttempts}) | input length=${code?.length}`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('Manual TOTP input failed or timed out')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const filled = await this.fillCode(page, code)
|
||||
if (!filled) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-TOTP',
|
||||
`Unable to fill TOTP input (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error('TOTP input field not found')
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(500)
|
||||
await this.bot.browser.utils.ghostClick(page, this.submitButtonSelector)
|
||||
await page.waitForLoadState('networkidle', { timeout: 5000 }).catch(() => {})
|
||||
|
||||
// Check if wrong code was entered
|
||||
const errorMessage = await getErrorMessage(page)
|
||||
if (errorMessage) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-TOTP',
|
||||
`Incorrect code: ${errorMessage} (attempt ${attempt}/${this.maxManualAttempts})`
|
||||
)
|
||||
|
||||
if (attempt === this.maxManualAttempts) {
|
||||
throw new Error(`Maximum attempts reached: ${errorMessage}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'LOGIN-TOTP', 'TOTP authentication completed successfully')
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`TOTP input failed after ${this.maxManualAttempts} attempts`)
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'LOGIN-TOTP',
|
||||
`Error occurred: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/config.example.json
Normal file
88
src/config.example.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"baseURL": "https://rewards.bing.com",
|
||||
"sessionPath": "sessions",
|
||||
"headless": false,
|
||||
"runOnZeroPoints": false,
|
||||
"clusters": 1,
|
||||
"errorDiagnostics": false,
|
||||
"workers": {
|
||||
"doDailySet": true,
|
||||
"doSpecialPromotions": true,
|
||||
"doMorePromotions": true,
|
||||
"doPunchCards": true,
|
||||
"doAppPromotions": true,
|
||||
"doDesktopSearch": true,
|
||||
"doMobileSearch": true,
|
||||
"doDailyCheckIn": true,
|
||||
"doReadToEarn": true
|
||||
},
|
||||
"searchOnBingLocalQueries": false,
|
||||
"globalTimeout": "30sec",
|
||||
"searchSettings": {
|
||||
"scrollRandomResults": false,
|
||||
"clickRandomResults": false,
|
||||
"parallelSearching": true,
|
||||
"queryEngines": [
|
||||
"google",
|
||||
"wikipedia",
|
||||
"reddit",
|
||||
"local"
|
||||
],
|
||||
"searchResultVisitTime": "10sec",
|
||||
"searchDelay": {
|
||||
"min": "30sec",
|
||||
"max": "1min"
|
||||
},
|
||||
"readDelay": {
|
||||
"min": "30sec",
|
||||
"max": "1min"
|
||||
}
|
||||
},
|
||||
"debugLogs": false,
|
||||
"consoleLogFilter": {
|
||||
"enabled": false,
|
||||
"mode": "whitelist",
|
||||
"levels": [
|
||||
"error",
|
||||
"warn"
|
||||
],
|
||||
"keywords": [
|
||||
"starting account"
|
||||
],
|
||||
"regexPatterns": []
|
||||
},
|
||||
"proxy": {
|
||||
"queryEngine": true
|
||||
},
|
||||
"webhook": {
|
||||
"discord": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
},
|
||||
"ntfy": {
|
||||
"enabled": false,
|
||||
"url": "",
|
||||
"topic": "",
|
||||
"token": "",
|
||||
"title": "Microsoft-Rewards-Script",
|
||||
"tags": [
|
||||
"bot",
|
||||
"notify"
|
||||
],
|
||||
"priority": 3
|
||||
},
|
||||
"webhookLogFilter": {
|
||||
"enabled": false,
|
||||
"mode": "whitelist",
|
||||
"levels": [
|
||||
"error"
|
||||
],
|
||||
"keywords": [
|
||||
"starting account",
|
||||
"select number",
|
||||
"collected"
|
||||
],
|
||||
"regexPatterns": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"baseURL": "https://rewards.bing.com",
|
||||
"sessionPath": "sessions",
|
||||
"headless": false,
|
||||
"runOnZeroPoints": false,
|
||||
"clusters": 1,
|
||||
"workers": {
|
||||
"doDailySet": true,
|
||||
"doMorePromotions": true,
|
||||
"doPunchCards": true,
|
||||
"doDesktopSearch": true,
|
||||
"doMobileSearch": true
|
||||
},
|
||||
"searchSettings": {
|
||||
"useGeoLocaleQueries": false,
|
||||
"scrollRandomResults": true,
|
||||
"clickRandomResults": true,
|
||||
"searchDelay": {
|
||||
"min": 10000,
|
||||
"max": 20000
|
||||
}
|
||||
},
|
||||
"webhook": {
|
||||
"enabled": false,
|
||||
"url": ""
|
||||
}
|
||||
}
|
||||
7
src/crontab.template
Normal file
7
src/crontab.template
Normal file
@@ -0,0 +1,7 @@
|
||||
# Set PATH so cron jobs can find node/npm
|
||||
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
|
||||
# Set timezone for cron jobs
|
||||
TZ=${TZ}
|
||||
|
||||
# Run automation according to CRON_SCHEDULE; redirect both stdout & stderr to Docker logs
|
||||
${CRON_SCHEDULE} /bin/bash /usr/src/microsoft-rewards-script/scripts/docker/run_daily.sh >> /proc/1/fd/1 2>&1
|
||||
@@ -1,16 +1,28 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
import type { Page } from 'patchright'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
// App
|
||||
import { DailyCheckIn } from './activities/app/DailyCheckIn'
|
||||
import { ReadToEarn } from './activities/app/ReadToEarn'
|
||||
import { AppReward } from './activities/app/AppReward'
|
||||
|
||||
import { Search } from './activities/Search'
|
||||
import { ABC } from './activities/ABC'
|
||||
import { Poll } from './activities/Poll'
|
||||
import { Quiz } from './activities/Quiz'
|
||||
import { ThisOrThat } from './activities/ThisOrThat'
|
||||
import { UrlReward } from './activities/UrlReward'
|
||||
// API
|
||||
import { UrlReward } from './activities/api/UrlReward'
|
||||
import { Quiz } from './activities/api/Quiz'
|
||||
import { FindClippy } from './activities/api/FindClippy'
|
||||
import { DoubleSearchPoints } from './activities/api/DoubleSearchPoints'
|
||||
|
||||
import { DashboardData } from '../interface/DashboardData'
|
||||
// Browser
|
||||
import { SearchOnBing } from './activities/browser/SearchOnBing'
|
||||
import { Search } from './activities/browser/Search'
|
||||
|
||||
import type {
|
||||
BasePromotion,
|
||||
DashboardData,
|
||||
FindClippyPromotion,
|
||||
PurplePromotionalItem
|
||||
} from '../interface/DashboardData'
|
||||
import type { Promotion } from '../interface/AppDashBoardData'
|
||||
|
||||
export default class Activities {
|
||||
private bot: MicrosoftRewardsBot
|
||||
@@ -19,34 +31,72 @@ export default class Activities {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
doSearch = async (page: Page, data: DashboardData, mobile: boolean): Promise<void> => {
|
||||
// Browser Activities
|
||||
doSearch = async (data: DashboardData, page: Page, isMobile: boolean): Promise<number> => {
|
||||
const search = new Search(this.bot)
|
||||
await search.doSearch(page, data, mobile)
|
||||
return await search.doSearch(data, page, isMobile)
|
||||
}
|
||||
|
||||
doSearchOnBing = async (promotion: BasePromotion, page: Page): Promise<void> => {
|
||||
const searchOnBing = new SearchOnBing(this.bot)
|
||||
await searchOnBing.doSearchOnBing(promotion, page)
|
||||
}
|
||||
|
||||
/*
|
||||
doABC = async (page: Page): Promise<void> => {
|
||||
const abc = new ABC(this.bot)
|
||||
await abc.doABC(page)
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
doPoll = async (page: Page): Promise<void> => {
|
||||
const poll = new Poll(this.bot)
|
||||
await poll.doPoll(page)
|
||||
}
|
||||
*/
|
||||
|
||||
/*
|
||||
doThisOrThat = async (page: Page): Promise<void> => {
|
||||
const thisOrThat = new ThisOrThat(this.bot)
|
||||
await thisOrThat.doThisOrThat(page)
|
||||
}
|
||||
*/
|
||||
|
||||
doQuiz = async (page: Page): Promise<void> => {
|
||||
const quiz = new Quiz(this.bot)
|
||||
await quiz.doQuiz(page)
|
||||
}
|
||||
|
||||
doUrlReward = async (page: Page): Promise<void> => {
|
||||
// API Activities
|
||||
doUrlReward = async (promotion: BasePromotion): Promise<void> => {
|
||||
const urlReward = new UrlReward(this.bot)
|
||||
await urlReward.doUrlReward(page)
|
||||
await urlReward.doUrlReward(promotion)
|
||||
}
|
||||
|
||||
}
|
||||
doQuiz = async (promotion: BasePromotion): Promise<void> => {
|
||||
const quiz = new Quiz(this.bot)
|
||||
await quiz.doQuiz(promotion)
|
||||
}
|
||||
|
||||
doFindClippy = async (promotion: FindClippyPromotion): Promise<void> => {
|
||||
const findClippy = new FindClippy(this.bot)
|
||||
await findClippy.doFindClippy(promotion)
|
||||
}
|
||||
|
||||
doDoubleSearchPoints = async (promotion: PurplePromotionalItem): Promise<void> => {
|
||||
const doubleSearchPoints = new DoubleSearchPoints(this.bot)
|
||||
await doubleSearchPoints.doDoubleSearchPoints(promotion)
|
||||
}
|
||||
|
||||
// App Activities
|
||||
doAppReward = async (promotion: Promotion): Promise<void> => {
|
||||
const urlReward = new AppReward(this.bot)
|
||||
await urlReward.doAppReward(promotion)
|
||||
}
|
||||
|
||||
doReadToEarn = async (): Promise<void> => {
|
||||
const readToEarn = new ReadToEarn(this.bot)
|
||||
await readToEarn.doReadToEarn()
|
||||
}
|
||||
|
||||
doDailyCheckIn = async (): Promise<void> => {
|
||||
const dailyCheckIn = new DailyCheckIn(this.bot)
|
||||
await dailyCheckIn.doDailyCheckIn()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import readline from 'readline'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
})
|
||||
|
||||
|
||||
export class Login {
|
||||
private bot: MicrosoftRewardsBot
|
||||
|
||||
constructor(bot: MicrosoftRewardsBot) {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
async login(page: Page, email: string, password: string) {
|
||||
|
||||
try {
|
||||
// Navigate to the Bing login page
|
||||
await page.goto('https://login.live.com/')
|
||||
|
||||
const isLoggedIn = await page.waitForSelector('html[data-role-name="MeePortal"]', { timeout: 10_000 }).then(() => true).catch(() => false)
|
||||
|
||||
if (!isLoggedIn) {
|
||||
const isLocked = await page.waitForSelector('.serviceAbusePageContainer', { visible: true, timeout: 10_000 }).then(() => true).catch(() => false)
|
||||
if (isLocked) {
|
||||
this.bot.log('LOGIN', 'This account has been locked!', 'error')
|
||||
throw new Error('Account has been locked!')
|
||||
}
|
||||
|
||||
await page.waitForSelector('#loginHeader', { visible: true, timeout: 10_000 })
|
||||
|
||||
await this.execLogin(page, email, password)
|
||||
this.bot.log('LOGIN', 'Logged into Microsoft successfully')
|
||||
} else {
|
||||
this.bot.log('LOGIN', 'Already logged in')
|
||||
}
|
||||
|
||||
// Check if logged in to bing
|
||||
await this.checkBingLogin(page)
|
||||
|
||||
// We're done logging in
|
||||
this.bot.log('LOGIN', 'Logged in successfully')
|
||||
|
||||
} catch (error) {
|
||||
// Throw and don't continue
|
||||
throw this.bot.log('LOGIN', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
private async execLogin(page: Page, email: string, password: string) {
|
||||
await page.type('#i0116', email)
|
||||
await page.click('#idSIButton9')
|
||||
|
||||
this.bot.log('LOGIN', 'Email entered successfully')
|
||||
|
||||
try {
|
||||
await page.waitForSelector('#i0118', { visible: true, timeout: 2000 })
|
||||
await this.bot.utils.wait(2000)
|
||||
|
||||
await page.type('#i0118', password)
|
||||
await page.click('#idSIButton9')
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log('LOGIN', '2FA code required')
|
||||
|
||||
const code = await new Promise<string>((resolve) => {
|
||||
rl.question('Enter 2FA code:\n', (input) => {
|
||||
rl.close()
|
||||
resolve(input)
|
||||
})
|
||||
})
|
||||
|
||||
await page.type('input[name="otc"]', code)
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
} finally {
|
||||
this.bot.log('LOGIN', 'Password entered successfully')
|
||||
}
|
||||
|
||||
const currentURL = new URL(page.url())
|
||||
|
||||
while (currentURL.pathname !== '/' || currentURL.hostname !== 'account.microsoft.com') {
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
currentURL.href = page.url()
|
||||
}
|
||||
|
||||
// Wait for login to complete
|
||||
await page.waitForSelector('html[data-role-name="MeePortal"]', { timeout: 10_000 })
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
const maxIterations = 5
|
||||
|
||||
for (let iteration = 1; iteration <= maxIterations; iteration++) {
|
||||
const currentUrl = new URL(page.url())
|
||||
|
||||
if (currentUrl.hostname === 'www.bing.com' && currentUrl.pathname === '/') {
|
||||
await this.bot.utils.wait(3000)
|
||||
await this.bot.browser.utils.tryDismissBingCookieBanner(page)
|
||||
|
||||
const loggedIn = await this.checkBingLoginStatus(page)
|
||||
if (loggedIn) {
|
||||
this.bot.log('LOGIN-BING', 'Bing login verification passed!')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log('LOGIN-BING', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
private async checkBingLoginStatus(page: Page): Promise<boolean> {
|
||||
try {
|
||||
await page.waitForSelector('#id_n', { timeout: 10_000 })
|
||||
return true
|
||||
} catch (error) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
500
src/functions/QueryEngine.ts
Normal file
500
src/functions/QueryEngine.ts
Normal file
@@ -0,0 +1,500 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import * as fs from 'fs'
|
||||
import path from 'path'
|
||||
import type { GoogleSearch, GoogleTrendsResponse, RedditListing, WikipediaTopResponse } from '../interface/Search'
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
import { QueryEngine } from '../interface/Config'
|
||||
|
||||
export class QueryCore {
|
||||
constructor(private bot: MicrosoftRewardsBot) {}
|
||||
|
||||
async queryManager(
|
||||
options: {
|
||||
shuffle?: boolean
|
||||
sourceOrder?: QueryEngine[]
|
||||
related?: boolean
|
||||
langCode?: string
|
||||
geoLocale?: string
|
||||
} = {}
|
||||
): Promise<string[]> {
|
||||
const {
|
||||
shuffle = false,
|
||||
sourceOrder = ['google', 'wikipedia', 'reddit', 'local'],
|
||||
related = true,
|
||||
langCode = 'en',
|
||||
geoLocale = 'US'
|
||||
} = options
|
||||
|
||||
try {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUERY-MANAGER',
|
||||
`start | shuffle=${shuffle}, related=${related}, lang=${langCode}, geo=${geoLocale}, sources=${sourceOrder.join(',')}`
|
||||
)
|
||||
|
||||
const topicLists: string[][] = []
|
||||
|
||||
const sourceHandlers: Record<
|
||||
'google' | 'wikipedia' | 'reddit' | 'local',
|
||||
(() => Promise<string[]>) | (() => string[])
|
||||
> = {
|
||||
google: async () => {
|
||||
const topics = await this.getGoogleTrends(geoLocale.toUpperCase()).catch(() => [])
|
||||
this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `google: ${topics.length}`)
|
||||
return topics
|
||||
},
|
||||
wikipedia: async () => {
|
||||
const topics = await this.getWikipediaTrending(langCode).catch(() => [])
|
||||
this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `wikipedia: ${topics.length}`)
|
||||
return topics
|
||||
},
|
||||
reddit: async () => {
|
||||
const topics = await this.getRedditTopics().catch(() => [])
|
||||
this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `reddit: ${topics.length}`)
|
||||
return topics
|
||||
},
|
||||
local: () => {
|
||||
const topics = this.getLocalQueryList()
|
||||
this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `local: ${topics.length}`)
|
||||
return topics
|
||||
}
|
||||
}
|
||||
|
||||
for (const source of sourceOrder) {
|
||||
const handler = sourceHandlers[source]
|
||||
if (!handler) continue
|
||||
|
||||
const topics = await Promise.resolve(handler())
|
||||
if (topics.length) topicLists.push(topics)
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUERY-MANAGER',
|
||||
`sources combined | rawTotal=${topicLists.flat().length}`
|
||||
)
|
||||
|
||||
const baseTopics = this.normalizeAndDedupe(topicLists.flat())
|
||||
|
||||
if (!baseTopics.length) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'QUERY-MANAGER', 'No queries')
|
||||
this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', 'No base topics found (all sources empty)')
|
||||
return []
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUERY-MANAGER',
|
||||
`baseTopics dedupe | before=${topicLists.flat().length} | after=${baseTopics.length}`
|
||||
)
|
||||
this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `baseTopics: ${baseTopics.length}`)
|
||||
|
||||
const clusters = related ? await this.buildRelatedClusters(baseTopics, langCode) : baseTopics.map(t => [t])
|
||||
|
||||
this.bot.utils.shuffleArray(clusters)
|
||||
this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', 'clusters shuffled')
|
||||
|
||||
let finalQueries = clusters.flat()
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUERY-MANAGER',
|
||||
`clusters flattened | total=${finalQueries.length}`
|
||||
)
|
||||
|
||||
// Do not cluster searches and shuffle
|
||||
if (shuffle) {
|
||||
this.bot.utils.shuffleArray(finalQueries)
|
||||
this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', 'finalQueries shuffled')
|
||||
}
|
||||
|
||||
finalQueries = this.normalizeAndDedupe(finalQueries)
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUERY-MANAGER',
|
||||
`finalQueries dedupe | after=${finalQueries.length}`
|
||||
)
|
||||
|
||||
if (!finalQueries.length) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'QUERY-MANAGER', 'No queries')
|
||||
this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', 'finalQueries deduped to 0')
|
||||
return []
|
||||
}
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `final queries: ${finalQueries.length}`)
|
||||
|
||||
return finalQueries
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'QUERY-MANAGER', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUERY-MANAGER',
|
||||
`error: ${error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error)}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async buildRelatedClusters(baseTopics: string[], langCode: string): Promise<string[][]> {
|
||||
const clusters: string[][] = []
|
||||
|
||||
const LIMIT = 50
|
||||
const head = baseTopics.slice(0, LIMIT)
|
||||
const tail = baseTopics.slice(LIMIT)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUERY-MANAGER',
|
||||
`related enabled | baseTopics=${baseTopics.length} | expand=${head.length} | passthrough=${tail.length} | lang=${langCode}`
|
||||
)
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUERY-MANAGER',
|
||||
`bing expansion enabled | limit=${LIMIT} | totalCalls=${head.length * 2}`
|
||||
)
|
||||
|
||||
for (const topic of head) {
|
||||
const suggestions = await this.getBingSuggestions(topic, langCode).catch(() => [])
|
||||
const relatedTerms = await this.getBingRelatedTerms(topic).catch(() => [])
|
||||
|
||||
const usedSuggestions = suggestions.slice(0, 6)
|
||||
const usedRelated = relatedTerms.slice(0, 3)
|
||||
|
||||
const cluster = this.normalizeAndDedupe([topic, ...usedSuggestions, ...usedRelated])
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUERY-MANAGER',
|
||||
`cluster expanded | topic="${topic}" | suggestions=${suggestions.length}->${usedSuggestions.length} | related=${relatedTerms.length}->${usedRelated.length} | clusterSize=${cluster.length}`
|
||||
)
|
||||
|
||||
clusters.push(cluster)
|
||||
}
|
||||
|
||||
if (tail.length) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'QUERY-MANAGER', `cluster passthrough | topics=${tail.length}`)
|
||||
|
||||
for (const topic of tail) {
|
||||
clusters.push([topic])
|
||||
}
|
||||
}
|
||||
|
||||
return clusters
|
||||
}
|
||||
|
||||
private normalizeAndDedupe(queries: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const out: string[] = []
|
||||
|
||||
for (const q of queries) {
|
||||
if (!q) continue
|
||||
const trimmed = q.trim()
|
||||
if (!trimmed) continue
|
||||
|
||||
const norm = trimmed.replace(/\s+/g, ' ').toLowerCase()
|
||||
if (seen.has(norm)) continue
|
||||
|
||||
seen.add(norm)
|
||||
out.push(trimmed)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
async getGoogleTrends(geoLocale: string): Promise<string[]> {
|
||||
const queryTerms: GoogleSearch[] = []
|
||||
|
||||
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.queryEngine)
|
||||
const trendsData = this.extractJsonFromResponse(response.data)
|
||||
if (!trendsData) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'No queries')
|
||||
this.bot.logger.debug(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'No trendsData parsed from response')
|
||||
return []
|
||||
}
|
||||
|
||||
const mapped = trendsData.map(q => [q[0], q[9]!.slice(1)])
|
||||
|
||||
if (mapped.length < 90 && geoLocale !== 'US') {
|
||||
return this.getGoogleTrends('US')
|
||||
}
|
||||
|
||||
for (const [topic, related] of mapped) {
|
||||
queryTerms.push({
|
||||
topic: topic as string,
|
||||
related: related as string[]
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-GOOGLE-TRENDS', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-GOOGLE-TRENDS',
|
||||
`request failed: ${
|
||||
error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error)
|
||||
}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
|
||||
return queryTerms.flatMap(x => [x.topic, ...x.related])
|
||||
}
|
||||
|
||||
private extractJsonFromResponse(text: string): GoogleTrendsResponse[1] | null {
|
||||
for (const line of text.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed.startsWith('[')) continue
|
||||
try {
|
||||
return JSON.parse(JSON.parse(trimmed)[0][2])[1]
|
||||
} catch {}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async getBingSuggestions(query = '', langCode = 'en'): Promise<string[]> {
|
||||
try {
|
||||
const request: AxiosRequestConfig = {
|
||||
url: `https://www.bingapis.com/api/v7/suggestions?q=${encodeURIComponent(
|
||||
query
|
||||
)}&appid=6D0A9B8C5100E9ECC7E11A104ADD76C10219804B&cc=xl&setlang=${langCode}`,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this.bot.fingerprint?.headers ?? {}),
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine)
|
||||
const suggestions =
|
||||
response.data.suggestionGroups?.[0]?.searchSuggestions?.map((x: { query: any }) => x.query) ?? []
|
||||
|
||||
if (!suggestions.length) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-SUGGESTIONS', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-BING-SUGGESTIONS',
|
||||
`empty suggestions | query="${query}" | lang=${langCode}`
|
||||
)
|
||||
}
|
||||
|
||||
return suggestions
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-SUGGESTIONS', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-BING-SUGGESTIONS',
|
||||
`request failed | query="${query}" | lang=${langCode} | error=${
|
||||
error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error)
|
||||
}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async getBingRelatedTerms(query: string): Promise<string[]> {
|
||||
try {
|
||||
const request: AxiosRequestConfig = {
|
||||
url: `https://api.bing.com/osjson.aspx?query=${encodeURIComponent(query)}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(this.bot.fingerprint?.headers ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine)
|
||||
const related = response.data?.[1]
|
||||
const out = Array.isArray(related) ? related : []
|
||||
|
||||
if (!out.length) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-RELATED', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-BING-RELATED',
|
||||
`empty related terms | query="${query}"`
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-RELATED', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-BING-RELATED',
|
||||
`request failed | query="${query}" | error=${
|
||||
error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error)
|
||||
}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async getBingTrendingTopics(langCode = 'en'): Promise<string[]> {
|
||||
try {
|
||||
const request: AxiosRequestConfig = {
|
||||
url: `https://www.bing.com/api/v7/news/trendingtopics?appid=91B36E34F9D1B900E54E85A77CF11FB3BE5279E6&cc=xl&setlang=${langCode}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||
'User-Agent':
|
||||
'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Rewards-Country': this.bot.userData.geoLocale,
|
||||
'X-Rewards-Language': 'en',
|
||||
'X-Rewards-ismobile': 'true'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine)
|
||||
const topics =
|
||||
response.data.value?.map(
|
||||
(x: { query: { text: string }; name: string }) => x.query?.text?.trim() || x.name.trim()
|
||||
) ?? []
|
||||
|
||||
if (!topics.length) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-TRENDING', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-BING-TRENDING',
|
||||
`empty trending topics | lang=${langCode}`
|
||||
)
|
||||
}
|
||||
|
||||
return topics
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-BING-TRENDING', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-BING-TRENDING',
|
||||
`request failed | lang=${langCode} | error=${
|
||||
error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error)
|
||||
}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async getWikipediaTrending(langCode = 'en'): Promise<string[]> {
|
||||
try {
|
||||
const date = new Date(Date.now() - 24 * 60 * 60 * 1000)
|
||||
const yyyy = date.getUTCFullYear()
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getUTCDate()).padStart(2, '0')
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
url: `https://wikimedia.org/api/rest_v1/metrics/pageviews/top/${langCode}.wikipedia/all-access/${yyyy}/${mm}/${dd}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(this.bot.fingerprint?.headers ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine)
|
||||
const articles = (response.data as WikipediaTopResponse).items?.[0]?.articles ?? []
|
||||
|
||||
const out = articles.slice(0, 50).map(a => a.article.replace(/_/g, ' '))
|
||||
|
||||
if (!out.length) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-WIKIPEDIA-TRENDING', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-WIKIPEDIA-TRENDING',
|
||||
`empty wikipedia top | lang=${langCode}`
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-WIKIPEDIA-TRENDING', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-WIKIPEDIA-TRENDING',
|
||||
`request failed | lang=${langCode} | error=${
|
||||
error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error)
|
||||
}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async getRedditTopics(subreddit = 'popular'): Promise<string[]> {
|
||||
try {
|
||||
const safe = subreddit.replace(/[^a-zA-Z0-9_+]/g, '')
|
||||
const request: AxiosRequestConfig = {
|
||||
url: `https://www.reddit.com/r/${safe}.json?limit=50`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
...(this.bot.fingerprint?.headers ?? {})
|
||||
}
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request, this.bot.config.proxy.queryEngine)
|
||||
const posts = (response.data as RedditListing).data?.children ?? []
|
||||
|
||||
const out = posts.filter(p => !p.data.over_18).map(p => p.data.title)
|
||||
|
||||
if (!out.length) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-REDDIT-TRENDING', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-REDDIT-TRENDING',
|
||||
`empty reddit listing | subreddit=${safe}`
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-REDDIT', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-REDDIT',
|
||||
`request failed | subreddit=${subreddit} | error=${
|
||||
error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error)
|
||||
}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
getLocalQueryList(): string[] {
|
||||
try {
|
||||
const file = path.join(__dirname, './search-queries.json')
|
||||
const queries = JSON.parse(fs.readFileSync(file, 'utf8')) as string[]
|
||||
const out = Array.isArray(queries) ? queries : []
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-LOCAL-QUERY-LIST',
|
||||
'local queries loaded | file=search-queries.json'
|
||||
)
|
||||
|
||||
if (!out.length) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-LOCAL-QUERY-LIST', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-LOCAL-QUERY-LIST',
|
||||
'search-queries.json parsed but empty or invalid'
|
||||
)
|
||||
}
|
||||
|
||||
return out
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(this.bot.isMobile, 'SEARCH-LOCAL-QUERY-LIST', 'No queries')
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-LOCAL-QUERY-LIST',
|
||||
`read/parse failed | error=${
|
||||
error instanceof Error ? `${error.name}: ${error.message}\n${error.stack ?? ''}` : String(error)
|
||||
}`
|
||||
)
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
618
src/functions/SearchManager.ts
Normal file
618
src/functions/SearchManager.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
import type { BrowserContext } from 'patchright'
|
||||
import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
||||
import { MicrosoftRewardsBot, executionContext } from '../index'
|
||||
import type { DashboardData } from '../interface/DashboardData'
|
||||
import type { Account } from '../interface/Account'
|
||||
|
||||
interface BrowserSession {
|
||||
context: BrowserContext
|
||||
fingerprint: BrowserFingerprintWithHeaders
|
||||
}
|
||||
|
||||
interface MissingSearchPoints {
|
||||
mobilePoints: number
|
||||
desktopPoints: number
|
||||
}
|
||||
|
||||
interface SearchResults {
|
||||
mobilePoints: number
|
||||
desktopPoints: number
|
||||
}
|
||||
|
||||
export class SearchManager {
|
||||
constructor(private bot: MicrosoftRewardsBot) {}
|
||||
|
||||
async doSearches(
|
||||
data: DashboardData,
|
||||
missingSearchPoints: MissingSearchPoints,
|
||||
mobileSession: BrowserSession,
|
||||
account: Account,
|
||||
accountEmail: string
|
||||
): Promise<SearchResults> {
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Start | account=${accountEmail} | mobileMissing=${missingSearchPoints.mobilePoints} | desktopMissing=${missingSearchPoints.desktopPoints}`
|
||||
)
|
||||
|
||||
const doMobile = this.bot.config.workers.doMobileSearch && missingSearchPoints.mobilePoints > 0
|
||||
const doDesktop = this.bot.config.workers.doDesktopSearch && missingSearchPoints.desktopPoints > 0
|
||||
|
||||
const mobileStatus = this.bot.config.workers.doMobileSearch
|
||||
? missingSearchPoints.mobilePoints > 0
|
||||
? 'run'
|
||||
: 'skip-no-points'
|
||||
: 'skip-disabled'
|
||||
const desktopStatus = this.bot.config.workers.doDesktopSearch
|
||||
? missingSearchPoints.desktopPoints > 0
|
||||
? 'run'
|
||||
: 'skip-no-points'
|
||||
: 'skip-disabled'
|
||||
|
||||
this.bot.logger.info(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Mobile: ${mobileStatus} (enabled=${this.bot.config.workers.doMobileSearch}, missing=${missingSearchPoints.mobilePoints})`
|
||||
)
|
||||
this.bot.logger.info(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Desktop: ${desktopStatus} (enabled=${this.bot.config.workers.doDesktopSearch}, missing=${missingSearchPoints.desktopPoints})`
|
||||
)
|
||||
|
||||
if (!doMobile && !doDesktop) {
|
||||
const bothWorkersEnabled = this.bot.config.workers.doMobileSearch && this.bot.config.workers.doDesktopSearch
|
||||
const bothNoPoints = missingSearchPoints.mobilePoints <= 0 && missingSearchPoints.desktopPoints <= 0
|
||||
|
||||
if (bothWorkersEnabled && bothNoPoints) {
|
||||
this.bot.logger.info(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
'All searches skipped: no mobile or desktop points left.'
|
||||
)
|
||||
} else {
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'No searches scheduled (disabled or no points).')
|
||||
}
|
||||
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Closing mobile session')
|
||||
try {
|
||||
await executionContext.run({ isMobile: true, account }, async () => {
|
||||
await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail)
|
||||
})
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Mobile session closed')
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Failed to close mobile session: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
if (error instanceof Error && error.stack) {
|
||||
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Mobile close stack: ${error.stack}`)
|
||||
}
|
||||
}
|
||||
return { mobilePoints: 0, desktopPoints: 0 }
|
||||
}
|
||||
|
||||
const useParallel = this.bot.config.searchSettings.parallelSearching
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', `Mode: ${useParallel ? 'parallel' : 'sequential'}`)
|
||||
this.bot.logger.debug('main', 'SEARCH-MANAGER', `parallelSearching=${useParallel} | account=${accountEmail}`)
|
||||
|
||||
if (useParallel) {
|
||||
return await this.doParallelSearches(
|
||||
data,
|
||||
missingSearchPoints,
|
||||
mobileSession,
|
||||
account,
|
||||
accountEmail,
|
||||
executionContext
|
||||
)
|
||||
} else {
|
||||
return await this.doSequentialSearches(
|
||||
data,
|
||||
missingSearchPoints,
|
||||
mobileSession,
|
||||
account,
|
||||
accountEmail,
|
||||
executionContext
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async doParallelSearches(
|
||||
data: DashboardData,
|
||||
missingSearchPoints: MissingSearchPoints,
|
||||
mobileSession: BrowserSession,
|
||||
account: Account,
|
||||
accountEmail: string,
|
||||
executionContext: any
|
||||
): Promise<SearchResults> {
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Parallel start')
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Parallel config | account=${accountEmail} | mobileMissing=${missingSearchPoints.mobilePoints} | desktopMissing=${missingSearchPoints.desktopPoints}`
|
||||
)
|
||||
|
||||
const shouldDoMobile = this.bot.config.workers.doMobileSearch && missingSearchPoints.mobilePoints > 0
|
||||
const shouldDoDesktop = this.bot.config.workers.doDesktopSearch && missingSearchPoints.desktopPoints > 0
|
||||
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Parallel flags | mobile=${shouldDoMobile} | desktop=${shouldDoDesktop}`
|
||||
)
|
||||
|
||||
let desktopSession: BrowserSession | null = null
|
||||
let mobileContextClosed = false
|
||||
|
||||
try {
|
||||
const promises: Promise<number>[] = []
|
||||
const searchTypes: string[] = []
|
||||
|
||||
if (shouldDoMobile) {
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Schedule mobile | target=${missingSearchPoints.mobilePoints}`
|
||||
)
|
||||
searchTypes.push('Mobile')
|
||||
promises.push(
|
||||
this.doMobileSearch(data, missingSearchPoints, mobileSession, accountEmail, executionContext).then(
|
||||
points => {
|
||||
mobileContextClosed = true
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', `Mobile done | earned=${points}`)
|
||||
return points
|
||||
}
|
||||
)
|
||||
)
|
||||
} else {
|
||||
const reason = !this.bot.config.workers.doMobileSearch ? 'disabled' : 'no-points'
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', `Skip mobile (${reason}); closing mobile session`)
|
||||
await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail)
|
||||
mobileContextClosed = true
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Mobile session closed (no mobile search)')
|
||||
}
|
||||
|
||||
if (shouldDoDesktop) {
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Desktop login start')
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Desktop login | account=${accountEmail} | proxy=${account.proxy ?? 'none'}`
|
||||
)
|
||||
desktopSession = await executionContext.run({ isMobile: false, accountEmail }, async () =>
|
||||
this.createDesktopSession(account, accountEmail)
|
||||
)
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Desktop login done')
|
||||
} else {
|
||||
const reason = !this.bot.config.workers.doDesktopSearch ? 'disabled' : 'no-points'
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', `Skip desktop login (${reason})`)
|
||||
}
|
||||
|
||||
if (shouldDoDesktop && desktopSession) {
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Schedule desktop | target=${missingSearchPoints.desktopPoints}`
|
||||
)
|
||||
searchTypes.push('Desktop')
|
||||
promises.push(
|
||||
this.doDesktopSearch(
|
||||
data,
|
||||
missingSearchPoints,
|
||||
desktopSession,
|
||||
accountEmail,
|
||||
executionContext
|
||||
).then(points => {
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', `Desktop done | earned=${points}`)
|
||||
return points
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', `Running parallel: ${searchTypes.join(' + ') || 'none'}`)
|
||||
|
||||
const results = await Promise.all(promises)
|
||||
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Parallel results | account=${accountEmail} | results=${JSON.stringify(results)}`
|
||||
)
|
||||
|
||||
const mobilePoints = shouldDoMobile ? (results[0] ?? 0) : 0
|
||||
const desktopPoints = shouldDoDesktop ? (results[shouldDoMobile ? 1 : 0] ?? 0) : 0
|
||||
|
||||
this.bot.logger.info(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Parallel summary | mobile=${mobilePoints} | desktop=${desktopPoints} | total=${
|
||||
mobilePoints + desktopPoints
|
||||
}`
|
||||
)
|
||||
|
||||
return { mobilePoints, desktopPoints }
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Parallel failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
if (error instanceof Error && error.stack) {
|
||||
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Parallel stack: ${error.stack}`)
|
||||
}
|
||||
throw error
|
||||
} finally {
|
||||
if (!mobileContextClosed && mobileSession) {
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Cleanup: closing mobile session')
|
||||
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Cleanup mobile | account=${accountEmail}`)
|
||||
try {
|
||||
await executionContext.run({ isMobile: true, accountEmail }, async () => {
|
||||
await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail)
|
||||
})
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Cleanup: mobile session closed')
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Cleanup: mobile close failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
if (error instanceof Error && error.stack) {
|
||||
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Cleanup mobile stack: ${error.stack}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async doSequentialSearches(
|
||||
data: DashboardData,
|
||||
missingSearchPoints: MissingSearchPoints,
|
||||
mobileSession: BrowserSession,
|
||||
account: Account,
|
||||
accountEmail: string,
|
||||
executionContext: any
|
||||
): Promise<SearchResults> {
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Sequential start')
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Sequential config | account=${accountEmail} | mobileMissing=${missingSearchPoints.mobilePoints} | desktopMissing=${missingSearchPoints.desktopPoints}`
|
||||
)
|
||||
|
||||
const shouldDoMobile = this.bot.config.workers.doMobileSearch && missingSearchPoints.mobilePoints > 0
|
||||
const shouldDoDesktop = this.bot.config.workers.doDesktopSearch && missingSearchPoints.desktopPoints > 0
|
||||
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Sequential flags | mobile=${shouldDoMobile} | desktop=${shouldDoDesktop}`
|
||||
)
|
||||
|
||||
let mobilePoints = 0
|
||||
let desktopPoints = 0
|
||||
|
||||
if (shouldDoMobile) {
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Step 1: mobile')
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Sequential mobile | target=${missingSearchPoints.mobilePoints}`
|
||||
)
|
||||
mobilePoints = await this.doMobileSearch(
|
||||
data,
|
||||
missingSearchPoints,
|
||||
mobileSession,
|
||||
accountEmail,
|
||||
executionContext
|
||||
)
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 1: mobile done | earned=${mobilePoints}`)
|
||||
} else {
|
||||
const reason = !this.bot.config.workers.doMobileSearch ? 'disabled' : 'no-points'
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 1: skip mobile (${reason}); closing mobile session`)
|
||||
this.bot.logger.debug('main', 'SEARCH-MANAGER', 'Closing unused mobile context')
|
||||
try {
|
||||
await executionContext.run({ isMobile: true, accountEmail }, async () => {
|
||||
await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail)
|
||||
})
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Unused mobile session closed')
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Unused mobile close failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
if (error instanceof Error && error.stack) {
|
||||
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Unused mobile stack: ${error.stack}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDoDesktop) {
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', 'Step 2: desktop')
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Sequential desktop | target=${missingSearchPoints.desktopPoints}`
|
||||
)
|
||||
desktopPoints = await this.doDesktopSearchSequential(
|
||||
data,
|
||||
missingSearchPoints,
|
||||
account,
|
||||
accountEmail,
|
||||
executionContext
|
||||
)
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 2: desktop done | earned=${desktopPoints}`)
|
||||
} else {
|
||||
const reason = !this.bot.config.workers.doDesktopSearch ? 'disabled' : 'no-points'
|
||||
this.bot.logger.info('main', 'SEARCH-MANAGER', `Step 2: skip desktop (${reason})`)
|
||||
}
|
||||
|
||||
this.bot.logger.info(
|
||||
'main',
|
||||
'SEARCH-MANAGER',
|
||||
`Sequential summary | mobile=${mobilePoints} | desktop=${desktopPoints} | total=${
|
||||
mobilePoints + desktopPoints
|
||||
}`
|
||||
)
|
||||
this.bot.logger.debug('main', 'SEARCH-MANAGER', `Sequential done | account=${accountEmail}`)
|
||||
|
||||
return { mobilePoints, desktopPoints }
|
||||
}
|
||||
|
||||
private async createDesktopSession(account: Account, accountEmail: string): Promise<BrowserSession> {
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Init desktop session')
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-LOGIN',
|
||||
`Init | account=${accountEmail} | proxy=${account.proxy ?? 'none'}`
|
||||
)
|
||||
|
||||
const session = await this.bot['browserFactory'].createBrowser(account)
|
||||
this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'Browser created, new page')
|
||||
|
||||
this.bot.mainDesktopPage = await session.context.newPage()
|
||||
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', `Browser ready | account=${accountEmail}`)
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Login start')
|
||||
this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'Calling login handler')
|
||||
|
||||
await this.bot['login'].login(this.bot.mainDesktopPage, account)
|
||||
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Login passed, verifying')
|
||||
this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'verifyBingSession')
|
||||
|
||||
await this.bot['login'].verifyBingSession(this.bot.mainDesktopPage)
|
||||
this.bot.cookies.desktop = await session.context.cookies()
|
||||
|
||||
this.bot.logger.debug('main', 'SEARCH-DESKTOP-LOGIN', 'Cookies stored')
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-LOGIN', 'Desktop session ready')
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
private async doMobileSearch(
|
||||
data: DashboardData,
|
||||
missingSearchPoints: MissingSearchPoints,
|
||||
mobileSession: BrowserSession,
|
||||
accountEmail: string,
|
||||
executionContext: any
|
||||
): Promise<number> {
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MOBILE-SEARCH',
|
||||
`Start | account=${accountEmail} | target=${missingSearchPoints.mobilePoints}`
|
||||
)
|
||||
|
||||
return await executionContext.run({ isMobile: true, accountEmail }, async () => {
|
||||
try {
|
||||
if (!this.bot.config.workers.doMobileSearch) {
|
||||
this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Skip: worker disabled in config')
|
||||
return 0
|
||||
}
|
||||
|
||||
if (missingSearchPoints.mobilePoints === 0) {
|
||||
this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Skip: no points left')
|
||||
return 0
|
||||
}
|
||||
|
||||
this.bot.logger.info(
|
||||
'main',
|
||||
'SEARCH-MOBILE-SEARCH',
|
||||
`Search start | target=${missingSearchPoints.mobilePoints}`
|
||||
)
|
||||
this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', 'activities.doSearch (mobile)')
|
||||
|
||||
const pointsEarned = await this.bot.activities.doSearch(data, this.bot.mainMobilePage, true)
|
||||
|
||||
this.bot.logger.info(
|
||||
'main',
|
||||
'SEARCH-MOBILE-SEARCH',
|
||||
`Search done | earned=${pointsEarned}/${missingSearchPoints.mobilePoints}`
|
||||
)
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-MOBILE-SEARCH',
|
||||
`Result | account=${accountEmail} | earned=${pointsEarned}`
|
||||
)
|
||||
|
||||
return pointsEarned
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
'main',
|
||||
'SEARCH-MOBILE-SEARCH',
|
||||
`Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
if (error instanceof Error && error.stack) {
|
||||
this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', `Stack: ${error.stack}`)
|
||||
}
|
||||
return 0
|
||||
} finally {
|
||||
this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Closing mobile session')
|
||||
this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', `Closing context | account=${accountEmail}`)
|
||||
try {
|
||||
await this.bot.browser.func.closeBrowser(mobileSession.context, accountEmail)
|
||||
this.bot.logger.info('main', 'SEARCH-MOBILE-SEARCH', 'Mobile browser closed')
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
'main',
|
||||
'SEARCH-MOBILE-SEARCH',
|
||||
`Close failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
if (error instanceof Error && error.stack) {
|
||||
this.bot.logger.debug('main', 'SEARCH-MOBILE-SEARCH', `Close stack: ${error.stack}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async doDesktopSearch(
|
||||
data: DashboardData,
|
||||
missingSearchPoints: MissingSearchPoints,
|
||||
desktopSession: BrowserSession,
|
||||
accountEmail: string,
|
||||
executionContext: any
|
||||
): Promise<number> {
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-PARALLEL',
|
||||
`Start | account=${accountEmail} | target=${missingSearchPoints.desktopPoints}`
|
||||
)
|
||||
|
||||
return await executionContext.run({ isMobile: false, accountEmail }, async () => {
|
||||
try {
|
||||
this.bot.logger.info(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-PARALLEL',
|
||||
`Search start | target=${missingSearchPoints.desktopPoints}`
|
||||
)
|
||||
const pointsEarned = await this.bot.activities.doSearch(data, this.bot.mainDesktopPage, false)
|
||||
|
||||
this.bot.logger.info(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-PARALLEL',
|
||||
`Search done | earned=${pointsEarned}/${missingSearchPoints.desktopPoints}`
|
||||
)
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-PARALLEL',
|
||||
`Result | account=${accountEmail} | earned=${pointsEarned}`
|
||||
)
|
||||
|
||||
return pointsEarned
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-PARALLEL',
|
||||
`Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
if (error instanceof Error && error.stack) {
|
||||
this.bot.logger.debug('main', 'SEARCH-DESKTOP-PARALLEL', `Stack: ${error.stack}`)
|
||||
}
|
||||
return 0
|
||||
} finally {
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-PARALLEL', 'Closing desktop session')
|
||||
this.bot.logger.debug('main', 'SEARCH-DESKTOP-PARALLEL', `Closing context | account=${accountEmail}`)
|
||||
try {
|
||||
await this.bot.browser.func.closeBrowser(desktopSession.context, accountEmail)
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-PARALLEL', 'Desktop browser closed')
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-PARALLEL',
|
||||
`Close failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
if (error instanceof Error && error.stack) {
|
||||
this.bot.logger.debug('main', 'SEARCH-DESKTOP-PARALLEL', `Close stack: ${error.stack}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async doDesktopSearchSequential(
|
||||
data: DashboardData,
|
||||
missingSearchPoints: MissingSearchPoints,
|
||||
account: Account,
|
||||
accountEmail: string,
|
||||
executionContext: any
|
||||
): Promise<number> {
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||
`Start | account=${accountEmail} | target=${missingSearchPoints.desktopPoints}`
|
||||
)
|
||||
|
||||
return await executionContext.run({ isMobile: false, accountEmail }, async () => {
|
||||
if (!this.bot.config.workers.doDesktopSearch) {
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Skip: worker disabled in config')
|
||||
return 0
|
||||
}
|
||||
|
||||
if (missingSearchPoints.desktopPoints === 0) {
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Skip: no points left')
|
||||
return 0
|
||||
}
|
||||
|
||||
let desktopSession: BrowserSession | null = null
|
||||
try {
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Init desktop session')
|
||||
desktopSession = await this.createDesktopSession(account, accountEmail)
|
||||
|
||||
this.bot.logger.info(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||
`Search start | target=${missingSearchPoints.desktopPoints}`
|
||||
)
|
||||
|
||||
const pointsEarned = await this.bot.activities.doSearch(data, this.bot.mainDesktopPage, false)
|
||||
|
||||
this.bot.logger.info(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||
`Search done | earned=${pointsEarned}/${missingSearchPoints.desktopPoints}`
|
||||
)
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||
`Result | account=${accountEmail} | earned=${pointsEarned}`
|
||||
)
|
||||
|
||||
return pointsEarned
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||
`Failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
if (error instanceof Error && error.stack) {
|
||||
this.bot.logger.debug('main', 'SEARCH-DESKTOP-SEQUENTIAL', `Stack: ${error.stack}`)
|
||||
}
|
||||
return 0
|
||||
} finally {
|
||||
if (desktopSession) {
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Closing desktop session')
|
||||
this.bot.logger.debug(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||
`Closing context | account=${accountEmail}`
|
||||
)
|
||||
try {
|
||||
await this.bot.browser.func.closeBrowser(desktopSession.context, accountEmail)
|
||||
this.bot.logger.info('main', 'SEARCH-DESKTOP-SEQUENTIAL', 'Desktop browser closed')
|
||||
} catch (error) {
|
||||
this.bot.logger.warn(
|
||||
'main',
|
||||
'SEARCH-DESKTOP-SEQUENTIAL',
|
||||
`Close failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
if (error instanceof Error && error.stack) {
|
||||
this.bot.logger.debug('main', 'SEARCH-DESKTOP-SEQUENTIAL', `Close stack: ${error.stack}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,13 @@
|
||||
import { Page } from 'puppeteer'
|
||||
|
||||
import { DashboardData, MorePromotion, PromotionalItem, PunchCard } from '../interface/DashboardData'
|
||||
|
||||
import { MicrosoftRewardsBot } from '../index'
|
||||
import type { Page } from 'patchright'
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
import type {
|
||||
DashboardData,
|
||||
PunchCard,
|
||||
BasePromotion,
|
||||
FindClippyPromotion,
|
||||
PurplePromotionalItem
|
||||
} from '../interface/DashboardData'
|
||||
import type { AppDashboardData } from '../interface/AppDashBoardData'
|
||||
|
||||
export class Workers {
|
||||
public bot: MicrosoftRewardsBot
|
||||
@@ -11,166 +16,263 @@ export class Workers {
|
||||
this.bot = bot
|
||||
}
|
||||
|
||||
// Daily Set
|
||||
async doDailySet(page: Page, data: DashboardData) {
|
||||
const todayData = data.dailySetPromotions[this.bot.utils.getFormattedDate()]
|
||||
public async doDailySet(data: DashboardData, page: Page) {
|
||||
const todayKey = this.bot.utils.getFormattedDate()
|
||||
const todayData = data.dailySetPromotions[todayKey]
|
||||
|
||||
const activitiesUncompleted = todayData?.filter(x => !x.complete && x.pointProgressMax > 0) ?? []
|
||||
|
||||
if (!activitiesUncompleted.length) {
|
||||
this.bot.log('DAILY-SET', 'All Daily Set" items have already been completed')
|
||||
this.bot.logger.info(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.logger.info(this.bot.isMobile, 'DAILY-SET', 'Started solving "Daily Set" items')
|
||||
|
||||
await this.solveActivities(page, activitiesUncompleted)
|
||||
await this.solveActivities(activitiesUncompleted, page)
|
||||
|
||||
this.bot.log('DAILY-SET', 'All "Daily Set" items have been completed')
|
||||
this.bot.logger.info(this.bot.isMobile, 'DAILY-SET', 'All "Daily Set" items have been completed')
|
||||
}
|
||||
|
||||
// Punch Card
|
||||
async doPunchCard(page: Page, data: DashboardData) {
|
||||
public async doMorePromotions(data: DashboardData, page: Page) {
|
||||
const morePromotions: BasePromotion[] = [
|
||||
...new Map(
|
||||
[...(data.morePromotions ?? []), ...(data.morePromotionsWithoutPromotionalItems ?? [])]
|
||||
.filter(Boolean)
|
||||
.map(p => [p.offerId, p as BasePromotion] as const)
|
||||
).values()
|
||||
]
|
||||
|
||||
const punchCardsUncompleted = data.punchCards?.filter(x => !x.parentPromotion.complete) ?? [] // Only return uncompleted punch cards
|
||||
const activitiesUncompleted: BasePromotion[] =
|
||||
morePromotions?.filter(x => {
|
||||
if (x.complete) return false
|
||||
if (x.pointProgressMax <= 0) return false
|
||||
if (x.exclusiveLockedFeatureStatus === 'locked') return false
|
||||
if (!x.promotionType) return false
|
||||
|
||||
if (!punchCardsUncompleted.length) {
|
||||
this.bot.log('PUNCH-CARD', 'All "Punch Cards" have already been completed')
|
||||
return
|
||||
}
|
||||
|
||||
for (const punchCard of punchCardsUncompleted) {
|
||||
const activitiesUncompleted = punchCard.childPromotions.filter(x => !x.complete) // Only return uncompleted activities
|
||||
|
||||
// Solve Activities
|
||||
this.bot.log('PUNCH-CARD', `Started solving "Punch Card" items for punchcard: "${punchCard.parentPromotion.title}"`)
|
||||
|
||||
const browser = page.browser()
|
||||
page = await browser.newPage()
|
||||
|
||||
// Got to punch card index page in a new tab
|
||||
await page.goto(punchCard.parentPromotion.destinationUrl, { referer: this.bot.config.baseURL })
|
||||
|
||||
await this.solveActivities(page, activitiesUncompleted, punchCard)
|
||||
|
||||
// Close the punch card index page
|
||||
await page.close()
|
||||
|
||||
this.bot.log('PUNCH-CARD', `All items for punchcard: "${punchCard.parentPromotion.title}" have been completed`)
|
||||
}
|
||||
|
||||
this.bot.log('PUNCH-CARD', 'All "Punch Card" items have been completed')
|
||||
}
|
||||
|
||||
// More Promotions
|
||||
async doMorePromotions(page: Page, data: DashboardData) {
|
||||
const morePromotions = data.morePromotions
|
||||
|
||||
// Check if there is a promotional item
|
||||
if (data.promotionalItem) { // Convert and add the promotional item to the array
|
||||
morePromotions.push(data.promotionalItem as unknown as MorePromotion)
|
||||
}
|
||||
|
||||
const activitiesUncompleted = morePromotions?.filter(x => !x.complete && x.pointProgressMax > 0) ?? []
|
||||
return true
|
||||
}) ?? []
|
||||
|
||||
if (!activitiesUncompleted.length) {
|
||||
this.bot.log('MORE-PROMOTIONS', 'All "More Promotion" items have already been completed')
|
||||
this.bot.logger.info(
|
||||
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.logger.info(
|
||||
this.bot.isMobile,
|
||||
'MORE-PROMOTIONS',
|
||||
`Started solving ${activitiesUncompleted.length} "More Promotions" items`
|
||||
)
|
||||
|
||||
await this.solveActivities(page, activitiesUncompleted)
|
||||
await this.solveActivities(activitiesUncompleted, page)
|
||||
|
||||
this.bot.log('MORE-PROMOTIONS', 'All "More Promotion" items have been completed')
|
||||
this.bot.logger.info(this.bot.isMobile, 'MORE-PROMOTIONS', 'All "More Promotion" items have been completed')
|
||||
}
|
||||
|
||||
// Solve all the different types of activities
|
||||
private async solveActivities(page: Page, activities: PromotionalItem[] | MorePromotion[], punchCard?: PunchCard) {
|
||||
public async doAppPromotions(data: AppDashboardData) {
|
||||
const appRewards = data.response.promotions.filter(x => {
|
||||
if (x.attributes['complete']?.toLowerCase() !== 'false') return false
|
||||
if (!x.attributes['offerid']) return false
|
||||
if (!x.attributes['type']) return false
|
||||
if (x.attributes['type'] !== 'sapphire') return false
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
if (!appRewards.length) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'APP-PROMOTIONS',
|
||||
'All "App Promotions" items have already been completed'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
for (const reward of appRewards) {
|
||||
await this.bot.activities.doAppReward(reward)
|
||||
// A delay between completing each activity
|
||||
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000))
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'APP-PROMOTIONS', 'All "App Promotions" items have been completed')
|
||||
}
|
||||
|
||||
public async doSpecialPromotions(data: DashboardData) {
|
||||
const specialPromotions: PurplePromotionalItem[] = [
|
||||
...new Map(
|
||||
[...(data.promotionalItems ?? [])]
|
||||
.filter(Boolean)
|
||||
.map(p => [p.offerId, p as PurplePromotionalItem] as const)
|
||||
).values()
|
||||
]
|
||||
|
||||
const supportedPromotions = ['ww_banner_optin_2x']
|
||||
|
||||
const specialPromotionsUncompleted: PurplePromotionalItem[] =
|
||||
specialPromotions?.filter(x => {
|
||||
if (x.complete) return false
|
||||
if (x.exclusiveLockedFeatureStatus === 'locked') return false
|
||||
if (!x.promotionType) return false
|
||||
|
||||
const offerId = (x.offerId ?? '').toLowerCase()
|
||||
return supportedPromotions.some(s => offerId.includes(s))
|
||||
}) ?? []
|
||||
|
||||
for (const activity of specialPromotionsUncompleted) {
|
||||
try {
|
||||
const type = activity.promotionType?.toLowerCase() ?? ''
|
||||
const name = activity.name?.toLowerCase() ?? ''
|
||||
const offerId = (activity as PurplePromotionalItem).offerId
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SPECIAL-ACTIVITY',
|
||||
`Processing activity | title="${activity.title}" | offerId=${offerId} | type=${type}"`
|
||||
)
|
||||
|
||||
switch (type) {
|
||||
// UrlReward
|
||||
case 'urlreward': {
|
||||
// Special "Double Search Points" activation
|
||||
if (name.includes('ww_banner_optin_2x')) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'ACTIVITY',
|
||||
`Found activity type "Double Search Points" | title="${activity.title}" | offerId=${offerId}`
|
||||
)
|
||||
|
||||
await this.bot.activities.doDoubleSearchPoints(activity)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Unsupported types
|
||||
default: {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'SPECIAL-ACTIVITY',
|
||||
`Skipped activity "${activity.title}" | offerId=${offerId} | Reason: Unsupported type "${activity.promotionType}"`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'SPECIAL-ACTIVITY',
|
||||
`Error while solving activity "${activity.title}" | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.logger.info(this.bot.isMobile, 'SPECIAL-ACTIVITY', 'All "Special Activites" items have been completed')
|
||||
}
|
||||
|
||||
private async solveActivities(activities: BasePromotion[], page: Page, punchCard?: PunchCard) {
|
||||
for (const activity of activities) {
|
||||
try {
|
||||
const type = activity.promotionType?.toLowerCase() ?? ''
|
||||
const name = activity.name?.toLowerCase() ?? ''
|
||||
const offerId = (activity as BasePromotion).offerId
|
||||
const destinationUrl = activity.destinationUrl?.toLowerCase() ?? ''
|
||||
|
||||
let selector = `[data-bi-id="${activity.offerId}"]`
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'ACTIVITY',
|
||||
`Processing activity | title="${activity.title}" | offerId=${offerId} | type=${type} | punchCard="${punchCard?.parentPromotion?.title ?? 'none'}"`
|
||||
)
|
||||
|
||||
if (punchCard) {
|
||||
selector = await this.bot.browser.func.getPunchCardActivity(page, activity)
|
||||
switch (type) {
|
||||
// Quiz-like activities (Poll / regular quiz variants)
|
||||
case 'quiz': {
|
||||
const basePromotion = activity as BasePromotion
|
||||
|
||||
} else if (activity.name.toLowerCase().includes('membercenter')) {
|
||||
// Poll (usually 10 points, pollscenarioid in URL)
|
||||
if (activity.pointProgressMax === 10 && destinationUrl.includes('pollscenarioid')) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'ACTIVITY',
|
||||
`Found activity type "Poll" | title="${activity.title}" | offerId=${offerId}`
|
||||
)
|
||||
|
||||
// Promotion
|
||||
if (activity.priority === 1) {
|
||||
selector = '#promo-item'
|
||||
} else {
|
||||
selector = `[data-bi-id="${activity.name}"]`
|
||||
//await this.bot.activities.doPoll(basePromotion)
|
||||
break
|
||||
}
|
||||
|
||||
// All other quizzes handled via Quiz API
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'ACTIVITY',
|
||||
`Found activity type "Quiz" | title="${activity.title}" | offerId=${offerId}`
|
||||
)
|
||||
|
||||
await this.bot.activities.doQuiz(basePromotion)
|
||||
break
|
||||
}
|
||||
|
||||
// UrlReward
|
||||
case 'urlreward': {
|
||||
const basePromotion = activity as BasePromotion
|
||||
|
||||
// Search on Bing are subtypes of "urlreward"
|
||||
if (name.includes('exploreonbing')) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'ACTIVITY',
|
||||
`Found activity type "SearchOnBing" | title="${activity.title}" | offerId=${offerId}`
|
||||
)
|
||||
|
||||
await this.bot.activities.doSearchOnBing(basePromotion, page)
|
||||
} else {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'ACTIVITY',
|
||||
`Found activity type "UrlReward" | title="${activity.title}" | offerId=${offerId}`
|
||||
)
|
||||
|
||||
await this.bot.activities.doUrlReward(basePromotion)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Find Clippy specific promotion type
|
||||
case 'findclippy': {
|
||||
const clippyPromotion = activity as unknown as FindClippyPromotion
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'ACTIVITY',
|
||||
`Found activity type "FindClippy" | title="${activity.title}" | offerId=${offerId}`
|
||||
)
|
||||
|
||||
await this.bot.activities.doFindClippy(clippyPromotion)
|
||||
break
|
||||
}
|
||||
|
||||
// Unsupported types
|
||||
default: {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'ACTIVITY',
|
||||
`Skipped activity "${activity.title}" | offerId=${offerId} | Reason: Unsupported type "${activity.promotionType}"`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for element to load
|
||||
await page.waitForSelector(selector, { timeout: 10_000 })
|
||||
|
||||
// Click element, it will be opened in a new tab
|
||||
await page.click(selector)
|
||||
|
||||
// Cooldown
|
||||
await this.bot.utils.wait(4000)
|
||||
|
||||
// Select the new activity page
|
||||
const activityPage = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
// Wait for body to load
|
||||
await activityPage.waitForSelector('body', { timeout: 10_000 })
|
||||
|
||||
switch (activity.promotionType) {
|
||||
// Quiz (Poll, Quiz or ABC)
|
||||
case 'quiz':
|
||||
switch (activity.pointProgressMax) {
|
||||
// Poll or ABC (Usually 10 points)
|
||||
case 10:
|
||||
// Normal poll
|
||||
if (activity.destinationUrl.toLowerCase().includes('pollscenarioid')) {
|
||||
this.bot.log('ACTIVITY', `Found activity type: "Poll" title: "${activity.title}"`)
|
||||
await this.bot.activities.doPoll(activityPage)
|
||||
} else { // ABC
|
||||
this.bot.log('ACTIVITY', `Found activity type: "ABC" title: "${activity.title}"`)
|
||||
await this.bot.activities.doABC(activityPage)
|
||||
}
|
||||
break
|
||||
|
||||
// This Or That Quiz (Usually 50 points)
|
||||
case 50:
|
||||
this.bot.log('ACTIVITY', `Found activity type: "ThisOrThat" title: "${activity.title}"`)
|
||||
await this.bot.activities.doThisOrThat(activityPage)
|
||||
break
|
||||
|
||||
// Quizzes are usually 30-40 points
|
||||
default:
|
||||
this.bot.log('ACTIVITY', `Found activity type: "Quiz" title: "${activity.title}"`)
|
||||
await this.bot.activities.doQuiz(activityPage)
|
||||
break
|
||||
}
|
||||
break
|
||||
|
||||
// UrlReward (Visit)
|
||||
case 'urlreward':
|
||||
this.bot.log('ACTIVITY', `Found activity type: "UrlReward" title: "${activity.title}"`)
|
||||
await this.bot.activities.doUrlReward(activityPage)
|
||||
break
|
||||
|
||||
// Misc, Usually UrlReward Type
|
||||
default:
|
||||
this.bot.log('ACTIVITY', `Found activity type: "Misc" title: "${activity.title}"`)
|
||||
await this.bot.activities.doUrlReward(activityPage)
|
||||
break
|
||||
}
|
||||
|
||||
// Cooldown
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000))
|
||||
} catch (error) {
|
||||
this.bot.log('ACTIVITY', 'An error occurred:' + error, 'error')
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'ACTIVITY',
|
||||
`Error while solving activity "${activity.title}" | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Page } from 'puppeteer'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
|
||||
export class ABC extends Workers {
|
||||
|
||||
async doABC(page: Page) {
|
||||
this.bot.log('ABC', 'Trying to complete poll')
|
||||
|
||||
try {
|
||||
let $ = await this.bot.browser.func.refreshCheerio(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 })
|
||||
|
||||
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 this.bot.utils.wait(2000)
|
||||
await page.click(`#${answer}`) // Click answer
|
||||
|
||||
await this.bot.utils.wait(4000)
|
||||
await page.waitForSelector('div.wk_button', { visible: true, timeout: 10_000 })
|
||||
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.utils.wait(4000)
|
||||
await page.close()
|
||||
|
||||
if (i === maxIterations) {
|
||||
this.bot.log('ABC', 'Failed to solve quiz, exceeded max iterations of 15', 'warn')
|
||||
} else {
|
||||
this.bot.log('ABC', 'Completed the ABC successfully')
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
await page.close()
|
||||
this.bot.log('ABC', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import { Page } from 'puppeteer'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
|
||||
export class Poll extends Workers {
|
||||
|
||||
async doPoll(page: Page) {
|
||||
this.bot.log('POLL', 'Trying to complete poll')
|
||||
|
||||
try {
|
||||
const buttonId = `#btoption${Math.floor(this.bot.utils.randomNumber(0, 1))}`
|
||||
|
||||
await page.waitForSelector(buttonId, { visible: true, timeout: 10_000 })
|
||||
await this.bot.utils.wait(2000)
|
||||
|
||||
await page.click(buttonId)
|
||||
|
||||
await this.bot.utils.wait(4000)
|
||||
await page.close()
|
||||
|
||||
this.bot.log('POLL', 'Completed the poll successfully')
|
||||
} catch (error) {
|
||||
await page.close()
|
||||
this.bot.log('POLL', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { Page } from 'puppeteer'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
|
||||
export class Quiz extends Workers {
|
||||
|
||||
async doQuiz(page: Page) {
|
||||
this.bot.log('QUIZ', 'Trying to complete quiz')
|
||||
|
||||
try {
|
||||
// Check if the quiz has been started or not
|
||||
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { visible: true, timeout: 3000 }).then(() => true).catch(() => false)
|
||||
if (quizNotStarted) {
|
||||
await page.click('#rqStartQuiz')
|
||||
} else {
|
||||
this.bot.log('QUIZ', 'Quiz has already been started, trying to finish it')
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(2000)
|
||||
|
||||
let quizData = await this.bot.browser.func.getQuizData(page)
|
||||
const questionsRemaining = quizData.maxQuestions - quizData.CorrectlyAnsweredQuestionCount // Amount of questions remaining
|
||||
|
||||
// All questions
|
||||
for (let question = 0; question < questionsRemaining; question++) {
|
||||
|
||||
if (quizData.numberOfOptions === 8) {
|
||||
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'))
|
||||
|
||||
if (answerAttribute && answerAttribute.toLowerCase() === 'true') {
|
||||
answers.push(`#rqAnswerOption${i}`)
|
||||
}
|
||||
}
|
||||
|
||||
// Click the answers
|
||||
for (const answer of answers) {
|
||||
await page.waitForSelector(answer, { visible: true, timeout: 2000 })
|
||||
|
||||
// Click the answer on page
|
||||
await page.click(answer)
|
||||
|
||||
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')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Other type quiz
|
||||
} else if ([2, 3, 4].includes(quizData.numberOfOptions)) {
|
||||
quizData = await this.bot.browser.func.getQuizData(page) // Refresh Quiz Data
|
||||
const correctOption = quizData.correctAnswer
|
||||
|
||||
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'))
|
||||
|
||||
if (dataOption === correctOption) {
|
||||
// Click the answer on page
|
||||
await page.click(`#rqAnswerOption${i}`)
|
||||
|
||||
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')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
await this.bot.utils.wait(2000)
|
||||
}
|
||||
}
|
||||
|
||||
// Done with
|
||||
await this.bot.utils.wait(2000)
|
||||
await page.close()
|
||||
this.bot.log('QUIZ', 'Completed the quiz successfully')
|
||||
} catch (error) {
|
||||
await page.close()
|
||||
this.bot.log('QUIZ', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,334 +0,0 @@
|
||||
import { Page } from 'puppeteer'
|
||||
import axios from 'axios'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
import { DashboardData, DashboardImpression } from '../../interface/DashboardData'
|
||||
import { GoogleTrends } from '../../interface/GoogleDailyTrends'
|
||||
import { GoogleSearch } from '../../interface/Search'
|
||||
|
||||
|
||||
export class Search extends Workers {
|
||||
|
||||
private searchPageURL = 'https://bing.com'
|
||||
|
||||
public async doSearch(page: Page, data: DashboardData, mobile: boolean) {
|
||||
this.bot.log('SEARCH-BING', 'Starting bing searches')
|
||||
|
||||
const mobileData = data.userStatus.counters?.mobileSearch ? data.userStatus.counters.mobileSearch[0] : null // Mobile searches
|
||||
const edgeData = data.userStatus.counters.pcSearch[1] as DashboardImpression // Edge searches
|
||||
const genericData = data.userStatus.counters.pcSearch[0] as DashboardImpression // Normal searches
|
||||
|
||||
let missingPoints = (mobile && mobileData) ?
|
||||
(mobileData.pointProgressMax - mobileData.pointProgress) :
|
||||
(edgeData.pointProgressMax - edgeData.pointProgress) + (genericData.pointProgressMax - genericData.pointProgress)
|
||||
|
||||
if (missingPoints == 0) {
|
||||
this.bot.log('SEARCH-BING', `Bing searches for ${mobile ? 'MOBILE' : 'DESKTOP'} have already been completed`)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate search queries
|
||||
let googleSearchQueries = await this.getGoogleTrends(data.userProfile.attributes.country, missingPoints)
|
||||
googleSearchQueries = this.bot.utils.shuffleArray(googleSearchQueries)
|
||||
|
||||
// Deduplicate the search terms
|
||||
googleSearchQueries = [...new Set(googleSearchQueries)]
|
||||
|
||||
// Open a new tab
|
||||
const browser = page.browser()
|
||||
const searchPage = await browser.newPage()
|
||||
|
||||
// Go to bing
|
||||
await searchPage.goto(this.searchPageURL)
|
||||
|
||||
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
|
||||
|
||||
const queries: string[] = []
|
||||
googleSearchQueries.forEach(x => queries.push(x.topic, ...x.related))
|
||||
|
||||
// Loop over Google search queries
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
const query = queries[i] as string
|
||||
|
||||
this.bot.log('SEARCH-BING', `${missingPoints} Points Remaining | Query: ${query} | Mobile: ${mobile}`)
|
||||
|
||||
const newData = await this.bingSearch(page, searchPage, query)
|
||||
|
||||
const newMobileData = newData.mobileSearch ? newData.mobileSearch[0] : null // Mobile searches
|
||||
const newEdgeData = newData.pcSearch[1] as DashboardImpression // Edge searches
|
||||
const newGenericData = newData.pcSearch[0] as DashboardImpression // Normal searches
|
||||
|
||||
const newMissingPoints = (mobile && newMobileData) ?
|
||||
(newMobileData.pointProgressMax - newMobileData.pointProgress) :
|
||||
(newEdgeData.pointProgressMax - newEdgeData.pointProgress) + (newGenericData.pointProgressMax - newGenericData.pointProgress)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
missingPoints = newMissingPoints
|
||||
|
||||
if (missingPoints == 0) {
|
||||
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
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 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`)
|
||||
|
||||
let i = 0
|
||||
while (missingPoints > 0) {
|
||||
const query = googleSearchQueries[i++] as GoogleSearch
|
||||
|
||||
// Get related search terms to the Google search queries
|
||||
const relatedTerms = await this.getRelatedTerms(query?.topic)
|
||||
if (relatedTerms.length > 3) {
|
||||
// Search for the first 2 related terms
|
||||
for (const term of relatedTerms.slice(1, 3)) {
|
||||
this.bot.log('SEARCH-BING-EXTRA', `${missingPoints} Points Remaining | Query: ${term} | Mobile: ${mobile}`)
|
||||
const newData = await this.bingSearch(page, searchPage, query.topic)
|
||||
|
||||
const newMobileData = newData.mobileSearch ? newData.mobileSearch[0] : null // Mobile searches
|
||||
const newEdgeData = newData.pcSearch[1] as DashboardImpression // Edge searches
|
||||
const newGenericData = newData.pcSearch[0] as DashboardImpression // Normal searches
|
||||
|
||||
const newMissingPoints = (mobile && newMobileData) ?
|
||||
(newMobileData.pointProgressMax - newMobileData.pointProgress) :
|
||||
(newEdgeData.pointProgressMax - newEdgeData.pointProgress) + (newGenericData.pointProgressMax - newGenericData.pointProgress)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
missingPoints = newMissingPoints
|
||||
|
||||
// If we satisfied the searches
|
||||
if (missingPoints == 0) {
|
||||
break
|
||||
}
|
||||
|
||||
// Try 5 more times, then we tried a total of 15 times, fair to say it's stuck
|
||||
if (maxLoop > 5) {
|
||||
this.bot.log('SEARCH-BING-EXTRA', 'Search didn\'t gain point for 5 iterations aborting searches', 'warn')
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.log('SEARCH-BING', 'Completed searches')
|
||||
}
|
||||
|
||||
private async bingSearch(page: Page, searchPage: Page, query: string) {
|
||||
// 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
|
||||
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')
|
||||
|
||||
if (this.bot.config.searchSettings.scrollRandomResults) {
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.randomScroll(searchPage)
|
||||
}
|
||||
|
||||
if (this.bot.config.searchSettings.clickRandomResults) {
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.clickRandomLink(searchPage)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(Math.floor(this.bot.utils.randomNumber(this.bot.config.searchSettings.searchDelay.min, this.bot.config.searchSettings.searchDelay.max)))
|
||||
|
||||
return await this.bot.browser.func.getSearchPoints(page)
|
||||
|
||||
} catch (error) {
|
||||
if (i === 5) {
|
||||
this.bot.log('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')
|
||||
|
||||
await this.bot.utils.wait(4000)
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.log('SEARCH-BING', 'Search failed after 5 retries, ending', 'error')
|
||||
return await this.bot.browser.func.getSearchPoints(page)
|
||||
}
|
||||
|
||||
private async getGoogleTrends(geoLocale: string, queryCount: number): Promise<GoogleSearch[]> {
|
||||
const queryTerms: GoogleSearch[] = []
|
||||
let i = 0
|
||||
|
||||
geoLocale = (this.bot.config.searchSettings.useGeoLocaleQueries && geoLocale.length === 2) ? geoLocale.toUpperCase() : 'US'
|
||||
|
||||
this.bot.log('SEARCH-GOOGLE-TRENDS', `Generating search queries, can take a while! | GeoLocale: ${geoLocale}`)
|
||||
|
||||
while (queryCount > queryTerms.length) {
|
||||
i += 1
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() - i)
|
||||
const formattedDate = this.formatDate(date)
|
||||
|
||||
try {
|
||||
const request = {
|
||||
url: `https://trends.google.com/trends/api/dailytrends?geo=${geoLocale}&hl=en&ed=${formattedDate}&ns=15`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios(request)
|
||||
|
||||
const data: GoogleTrends = JSON.parse((await response.data).slice(5))
|
||||
|
||||
for (const topic of data.default.trendingSearchesDays[0]?.trendingSearches ?? []) {
|
||||
queryTerms.push({
|
||||
topic: topic.title.query.toLowerCase(),
|
||||
related: topic.relatedQueries.map(x => x.query.toLocaleLowerCase())
|
||||
})
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
this.bot.log('SEARCH-GOOGLE-TRENDS', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return queryTerms
|
||||
}
|
||||
|
||||
private async getRelatedTerms(term: string): Promise<string[]> {
|
||||
try {
|
||||
const request = {
|
||||
url: `https://api.bing.com/osjson.aspx?query=${term}`,
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios(request)
|
||||
|
||||
return response.data[1] as string[]
|
||||
} catch (error) {
|
||||
this.bot.log('SEARCH-BING-RELTATED', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private formatDate(date: Date): string {
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
|
||||
return `${year}${month}${day}`
|
||||
}
|
||||
|
||||
private async randomScroll(page: Page) {
|
||||
try {
|
||||
// Press the arrow down key to scroll
|
||||
for (let i = 0; i < this.bot.utils.randomNumber(5, 100); i++) {
|
||||
await page.keyboard.press('ArrowDown')
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.log('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').catch(() => { }) // Since we don't really care if it did it or not
|
||||
|
||||
// Wait for website to load
|
||||
await this.bot.utils.wait(3000)
|
||||
|
||||
// Will get current tab if no new one is created
|
||||
let lastTab = await this.bot.browser.utils.getLatestTab(page)
|
||||
|
||||
// Wait for the body of the new page to be loaded
|
||||
await lastTab.waitForSelector('body', { timeout: 10_000 }).catch(() => { })
|
||||
|
||||
// Check if the tab is closed or not
|
||||
if (!lastTab.isClosed()) {
|
||||
let lastTabURL = new URL(lastTab.url()) // Get new tab info
|
||||
|
||||
// Check if the URL is different from the original one, don't loop more than 5 times.
|
||||
let i = 0
|
||||
while (lastTabURL.href !== searchListingURL.href && i < 5) {
|
||||
// If hostname is still bing, (Bing images/news etc)
|
||||
if (lastTabURL.hostname == searchListingURL.hostname) {
|
||||
await lastTab.goBack()
|
||||
|
||||
lastTab = await this.bot.browser.utils.getLatestTab(page) // Get last opened tab
|
||||
|
||||
// If "goBack" didn't return to search listing (due to redirects)
|
||||
if (lastTabURL.hostname !== searchListingURL.hostname) {
|
||||
await lastTab.goto(this.searchPageURL)
|
||||
}
|
||||
|
||||
} else { // No longer on bing, likely opened a new tab, close this tab
|
||||
lastTab = await this.bot.browser.utils.getLatestTab(page) // Get last opened tab
|
||||
lastTabURL = new URL(lastTab.url())
|
||||
|
||||
const tabs = await (page.browser()).pages() // Get all tabs open
|
||||
|
||||
// If the browser has more than 3 tabs open, it has opened a new one, we need to close this one.
|
||||
if (tabs.length > 3) {
|
||||
// Make sure the page is still open!
|
||||
if (!lastTab.isClosed()) {
|
||||
await lastTab.close()
|
||||
}
|
||||
|
||||
} else if (lastTabURL.href !== searchListingURL.href) {
|
||||
|
||||
await lastTab.goBack()
|
||||
|
||||
lastTab = await this.bot.browser.utils.getLatestTab(page) // Get last opened tab
|
||||
lastTabURL = new URL(lastTab.url())
|
||||
|
||||
// If "goBack" didn't return to search listing (due to redirects)
|
||||
if (lastTabURL.hostname !== searchListingURL.hostname) {
|
||||
await lastTab.goto(this.searchPageURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
lastTab = await this.bot.browser.utils.getLatestTab(page) // Finally update the lastTab var again
|
||||
i++
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.log('SEARCH-RANDOM-CLICK', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Page } from 'puppeteer'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
|
||||
export class ThisOrThat extends Workers {
|
||||
|
||||
async doThisOrThat(page: Page) {
|
||||
this.bot.log('THIS-OR-THAT', 'Trying to complete ThisOrThat')
|
||||
|
||||
return
|
||||
try {
|
||||
// Check if the quiz has been started or not
|
||||
const quizNotStarted = await page.waitForSelector('#rqStartQuiz', { visible: true, timeout: 3000 }).then(() => true).catch(() => false)
|
||||
if (quizNotStarted) {
|
||||
await page.click('#rqStartQuiz')
|
||||
} else {
|
||||
this.bot.log('THIS-OR-THAT', 'ThisOrThat has already been started, trying to finish it')
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(2000)
|
||||
|
||||
// Solving
|
||||
const quizData = await this.bot.browser.func.getQuizData(page)
|
||||
quizData // correctAnswer property is always null?
|
||||
|
||||
this.bot.log('THIS-OR-THAT', 'Completed the ThisOrthat successfully')
|
||||
} catch (error) {
|
||||
await page.close()
|
||||
this.bot.log('THIS-OR-THAT', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Page } from 'puppeteer'
|
||||
|
||||
import { Workers } from '../Workers'
|
||||
|
||||
|
||||
export class UrlReward extends Workers {
|
||||
|
||||
async doUrlReward(page: Page) {
|
||||
this.bot.log('URL-REWARD', 'Trying to complete UrlReward')
|
||||
|
||||
try {
|
||||
// After waiting, close the page
|
||||
await page.close()
|
||||
|
||||
this.bot.log('URL-REWARD', 'Completed the UrlReward successfully')
|
||||
} catch (error) {
|
||||
await page.close()
|
||||
this.bot.log('URL-REWARD', 'An error occurred:' + error, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
124
src/functions/activities/api/DoubleSearchPoints.ts
Normal file
124
src/functions/activities/api/DoubleSearchPoints.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { Workers } from '../../Workers'
|
||||
import { PromotionalItem } from '../../../interface/DashboardData'
|
||||
|
||||
export class DoubleSearchPoints extends Workers {
|
||||
private cookieHeader: string = ''
|
||||
|
||||
private fingerprintHeader: { [x: string]: string } = {}
|
||||
|
||||
public async doDoubleSearchPoints(promotion: PromotionalItem) {
|
||||
const offerId = promotion.offerId
|
||||
const activityType = promotion.activityType
|
||||
|
||||
try {
|
||||
if (!this.bot.requestToken) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'DOUBLE-SEARCH-POINTS',
|
||||
'Skipping: Request token not available, this activity requires it!'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop)
|
||||
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||
.join('; ')
|
||||
|
||||
const fingerprintHeaders = { ...this.bot.fingerprint.headers }
|
||||
delete fingerprintHeaders['Cookie']
|
||||
delete fingerprintHeaders['cookie']
|
||||
this.fingerprintHeader = fingerprintHeaders
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'DOUBLE-SEARCH-POINTS',
|
||||
`Starting Double Search Points | offerId=${offerId}`
|
||||
)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DOUBLE-SEARCH-POINTS',
|
||||
`Prepared headers | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}`
|
||||
)
|
||||
|
||||
const formData = new URLSearchParams({
|
||||
id: offerId,
|
||||
hash: promotion.hash,
|
||||
timeZone: '60',
|
||||
activityAmount: '1',
|
||||
dbs: '0',
|
||||
form: '',
|
||||
type: activityType,
|
||||
__RequestVerificationToken: this.bot.requestToken
|
||||
})
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DOUBLE-SEARCH-POINTS',
|
||||
`Prepared Double Search Points form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1 | type=${activityType}`
|
||||
)
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this.bot.fingerprint?.headers ?? {}),
|
||||
Cookie: this.cookieHeader,
|
||||
Referer: 'https://rewards.bing.com/',
|
||||
Origin: 'https://rewards.bing.com'
|
||||
},
|
||||
data: formData
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DOUBLE-SEARCH-POINTS',
|
||||
`Sending Double Search Points request | offerId=${offerId} | url=${request.url}`
|
||||
)
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DOUBLE-SEARCH-POINTS',
|
||||
`Received Double Search Points response | offerId=${offerId} | status=${response.status}`
|
||||
)
|
||||
|
||||
const data = await this.bot.browser.func.getDashboardData()
|
||||
const promotionalItem = data.promotionalItems.find(item =>
|
||||
item.name.toLowerCase().includes('ww_banner_optin_2x')
|
||||
)
|
||||
|
||||
// If OK, should no longer be presernt in promotionalItems
|
||||
if (promotionalItem) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'DOUBLE-SEARCH-POINTS',
|
||||
`Unable to find or activate Double Search Points | offerId=${offerId} | status=${response.status}`
|
||||
)
|
||||
} else {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'DOUBLE-SEARCH-POINTS',
|
||||
`Activated Double Search Points | offerId=${offerId} | status=${response.status}`,
|
||||
'green'
|
||||
)
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DOUBLE-SEARCH-POINTS',
|
||||
`Waiting after Double Search Points | offerId=${offerId}`
|
||||
)
|
||||
|
||||
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000))
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'DOUBLE-SEARCH-POINTS',
|
||||
`Error in doDoubleSearchPoints | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
130
src/functions/activities/api/FindClippy.ts
Normal file
130
src/functions/activities/api/FindClippy.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { FindClippyPromotion } from '../../../interface/DashboardData'
|
||||
import { Workers } from '../../Workers'
|
||||
|
||||
export class FindClippy extends Workers {
|
||||
private cookieHeader: string = ''
|
||||
|
||||
private fingerprintHeader: { [x: string]: string } = {}
|
||||
|
||||
private gainedPoints: number = 0
|
||||
|
||||
private oldBalance: number = this.bot.userData.currentPoints
|
||||
|
||||
public async doFindClippy(promotion: FindClippyPromotion) {
|
||||
const offerId = promotion.offerId
|
||||
const activityType = promotion.activityType
|
||||
|
||||
try {
|
||||
if (!this.bot.requestToken) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'FIND-CLIPPY',
|
||||
'Skipping: Request token not available, this activity requires it!'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop)
|
||||
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||
.join('; ')
|
||||
|
||||
const fingerprintHeaders = { ...this.bot.fingerprint.headers }
|
||||
delete fingerprintHeaders['Cookie']
|
||||
delete fingerprintHeaders['cookie']
|
||||
this.fingerprintHeader = fingerprintHeaders
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'FIND-CLIPPY',
|
||||
`Starting Find Clippy | offerId=${offerId} | activityType=${activityType} | oldBalance=${this.oldBalance}`
|
||||
)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'FIND-CLIPPY',
|
||||
`Prepared headers | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}`
|
||||
)
|
||||
|
||||
const formData = new URLSearchParams({
|
||||
id: offerId,
|
||||
hash: promotion.hash,
|
||||
timeZone: '60',
|
||||
activityAmount: '1',
|
||||
dbs: '0',
|
||||
form: '',
|
||||
type: activityType,
|
||||
__RequestVerificationToken: this.bot.requestToken
|
||||
})
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'FIND-CLIPPY',
|
||||
`Prepared Find Clippy form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1 | type=${activityType}`
|
||||
)
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this.bot.fingerprint?.headers ?? {}),
|
||||
Cookie: this.cookieHeader,
|
||||
Referer: 'https://rewards.bing.com/',
|
||||
Origin: 'https://rewards.bing.com'
|
||||
},
|
||||
data: formData
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'FIND-CLIPPY',
|
||||
`Sending Find Clippy request | offerId=${offerId} | url=${request.url}`
|
||||
)
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'FIND-CLIPPY',
|
||||
`Received Find Clippy response | offerId=${offerId} | status=${response.status}`
|
||||
)
|
||||
|
||||
const newBalance = await this.bot.browser.func.getCurrentPoints()
|
||||
this.gainedPoints = newBalance - this.oldBalance
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'FIND-CLIPPY',
|
||||
`Balance delta after Find Clippy | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||
)
|
||||
|
||||
if (this.gainedPoints > 0) {
|
||||
this.bot.userData.currentPoints = newBalance
|
||||
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'FIND-CLIPPY',
|
||||
`Found Clippy | offerId=${offerId} | status=${response.status} | gainedPoints=${this.gainedPoints} | newBalance=${newBalance}`,
|
||||
'green'
|
||||
)
|
||||
} else {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'FIND-CLIPPY',
|
||||
`Found Clippy but no points were gained | offerId=${offerId} | status=${response.status} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`
|
||||
)
|
||||
}
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'FIND-CLIPPY', `Waiting after Find Clippy | offerId=${offerId}`)
|
||||
|
||||
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000))
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'FIND-CLIPPY',
|
||||
`Error in doFindClippy | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
173
src/functions/activities/api/Quiz.ts
Normal file
173
src/functions/activities/api/Quiz.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { BasePromotion } from '../../../interface/DashboardData'
|
||||
import { Workers } from '../../Workers'
|
||||
|
||||
export class Quiz extends Workers {
|
||||
private cookieHeader: string = ''
|
||||
|
||||
private fingerprintHeader: { [x: string]: string } = {}
|
||||
|
||||
private gainedPoints: number = 0
|
||||
|
||||
private oldBalance: number = this.bot.userData.currentPoints
|
||||
|
||||
async doQuiz(promotion: BasePromotion) {
|
||||
const offerId = promotion.offerId
|
||||
this.oldBalance = Number(this.bot.userData.currentPoints ?? 0)
|
||||
const startBalance = this.oldBalance
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Starting quiz | offerId=${offerId} | pointProgressMax=${promotion.pointProgressMax} | activityProgressMax=${promotion.activityProgressMax} | currentPoints=${startBalance}`
|
||||
)
|
||||
|
||||
try {
|
||||
this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop)
|
||||
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||
.join('; ')
|
||||
|
||||
const fingerprintHeaders = { ...this.bot.fingerprint.headers }
|
||||
delete fingerprintHeaders['Cookie']
|
||||
delete fingerprintHeaders['cookie']
|
||||
this.fingerprintHeader = fingerprintHeaders
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Prepared quiz headers | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}`
|
||||
)
|
||||
|
||||
// 8-question quiz
|
||||
if (promotion.activityProgressMax === 80) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Detected 8-question quiz (activityProgressMax=80), marking as completed | offerId=${offerId}`
|
||||
)
|
||||
|
||||
// Not implemented
|
||||
return
|
||||
}
|
||||
|
||||
//Standard points quizzes (20/30/40/50 max)
|
||||
if ([20, 30, 40, 50].includes(promotion.pointProgressMax)) {
|
||||
let oldBalance = startBalance
|
||||
let gainedPoints = 0
|
||||
const maxAttempts = 20
|
||||
let totalGained = 0
|
||||
let attempts = 0
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Starting ReportActivity loop | offerId=${offerId} | maxAttempts=${maxAttempts} | startingBalance=${oldBalance}`
|
||||
)
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const jsonData = {
|
||||
UserId: null,
|
||||
TimeZoneOffset: -60,
|
||||
OfferId: offerId,
|
||||
ActivityCount: 1,
|
||||
QuestionIndex: '-1'
|
||||
}
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://www.bing.com/bingqa/ReportActivity?ajaxreq=1',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
|
||||
cookie: this.cookieHeader,
|
||||
...this.fingerprintHeader
|
||||
},
|
||||
data: JSON.stringify(jsonData)
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Sending ReportActivity request | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | url=${request.url}`
|
||||
)
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Received ReportActivity response | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | status=${response.status}`
|
||||
)
|
||||
|
||||
const newBalance = await this.bot.browser.func.getCurrentPoints()
|
||||
gainedPoints = newBalance - oldBalance
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Balance delta after ReportActivity | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | oldBalance=${oldBalance} | newBalance=${newBalance} | gainedPoints=${gainedPoints}`
|
||||
)
|
||||
|
||||
attempts = i + 1
|
||||
|
||||
if (gainedPoints > 0) {
|
||||
this.bot.userData.currentPoints = newBalance
|
||||
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints
|
||||
|
||||
oldBalance = newBalance
|
||||
totalGained += gainedPoints
|
||||
this.gainedPoints += gainedPoints
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`ReportActivity ${i + 1} → ${response.status} | offerId=${offerId} | gainedPoints=${gainedPoints} | newBalance=${newBalance}`,
|
||||
'green'
|
||||
)
|
||||
} else {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`ReportActivity ${i + 1} | offerId=${offerId} | no more points gained, ending quiz | lastBalance=${newBalance}`
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Waiting between ReportActivity attempts | attempt=${i + 1}/${maxAttempts} | offerId=${offerId}`
|
||||
)
|
||||
|
||||
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 7000))
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Error during ReportActivity | attempt=${i + 1}/${maxAttempts} | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Completed the quiz successfully | offerId=${offerId} | attempts=${attempts} | totalGained=${totalGained} | startBalance=${startBalance} | finalBalance=${this.bot.userData.currentPoints}`
|
||||
)
|
||||
} else {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Unsupported quiz configuration | offerId=${offerId} | pointProgressMax=${promotion.pointProgressMax} | activityProgressMax=${promotion.activityProgressMax}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'QUIZ',
|
||||
`Error in doQuiz | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/functions/activities/api/UrlReward.ts
Normal file
129
src/functions/activities/api/UrlReward.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { BasePromotion } from '../../../interface/DashboardData'
|
||||
import { Workers } from '../../Workers'
|
||||
|
||||
export class UrlReward extends Workers {
|
||||
private cookieHeader: string = ''
|
||||
|
||||
private fingerprintHeader: { [x: string]: string } = {}
|
||||
|
||||
private gainedPoints: number = 0
|
||||
|
||||
private oldBalance: number = this.bot.userData.currentPoints
|
||||
|
||||
public async doUrlReward(promotion: BasePromotion) {
|
||||
if (!this.bot.requestToken) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'URL-REWARD',
|
||||
'Skipping: Request token not available, this activity requires it!'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const offerId = promotion.offerId
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'URL-REWARD',
|
||||
`Starting UrlReward | offerId=${offerId} | geo=${this.bot.userData.geoLocale} | oldBalance=${this.oldBalance}`
|
||||
)
|
||||
|
||||
try {
|
||||
this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop)
|
||||
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||
.join('; ')
|
||||
|
||||
const fingerprintHeaders = { ...this.bot.fingerprint.headers }
|
||||
delete fingerprintHeaders['Cookie']
|
||||
delete fingerprintHeaders['cookie']
|
||||
this.fingerprintHeader = fingerprintHeaders
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'URL-REWARD',
|
||||
`Prepared UrlReward headers | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}`
|
||||
)
|
||||
|
||||
const formData = new URLSearchParams({
|
||||
id: offerId,
|
||||
hash: promotion.hash,
|
||||
timeZone: '60',
|
||||
activityAmount: '1',
|
||||
dbs: '0',
|
||||
form: '',
|
||||
type: '',
|
||||
__RequestVerificationToken: this.bot.requestToken
|
||||
})
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'URL-REWARD',
|
||||
`Prepared UrlReward form data | offerId=${offerId} | hash=${promotion.hash} | timeZone=60 | activityAmount=1`
|
||||
)
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this.bot.fingerprint?.headers ?? {}),
|
||||
Cookie: this.cookieHeader,
|
||||
Referer: 'https://rewards.bing.com/',
|
||||
Origin: 'https://rewards.bing.com'
|
||||
},
|
||||
data: formData
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'URL-REWARD',
|
||||
`Sending UrlReward request | offerId=${offerId} | url=${request.url}`
|
||||
)
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'URL-REWARD',
|
||||
`Received UrlReward response | offerId=${offerId} | status=${response.status}`
|
||||
)
|
||||
|
||||
const newBalance = await this.bot.browser.func.getCurrentPoints()
|
||||
this.gainedPoints = newBalance - this.oldBalance
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'URL-REWARD',
|
||||
`Balance delta after UrlReward | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||
)
|
||||
|
||||
if (this.gainedPoints > 0) {
|
||||
this.bot.userData.currentPoints = newBalance
|
||||
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'URL-REWARD',
|
||||
`Completed UrlReward | offerId=${offerId} | status=${response.status} | gainedPoints=${this.gainedPoints} | newBalance=${newBalance}`,
|
||||
'green'
|
||||
)
|
||||
} else {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'URL-REWARD',
|
||||
`Failed UrlReward with no points | offerId=${offerId} | status=${response.status} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`
|
||||
)
|
||||
}
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'URL-REWARD', `Waiting after UrlReward | offerId=${offerId}`)
|
||||
|
||||
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000))
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'URL-REWARD',
|
||||
`Error in doUrlReward | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
119
src/functions/activities/app/AppReward.ts
Normal file
119
src/functions/activities/app/AppReward.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { Promotion } from '../../../interface/AppDashBoardData'
|
||||
import { Workers } from '../../Workers'
|
||||
|
||||
export class AppReward extends Workers {
|
||||
private gainedPoints: number = 0
|
||||
|
||||
private oldBalance: number = this.bot.userData.currentPoints
|
||||
|
||||
public async doAppReward(promotion: Promotion) {
|
||||
if (!this.bot.accessToken) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'APP-REWARD',
|
||||
'Skipping: App access token not available, this activity requires it!'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const offerId = promotion.attributes['offerid']
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'APP-REWARD',
|
||||
`Starting AppReward | offerId=${offerId} | country=${this.bot.userData.geoLocale} | oldBalance=${this.oldBalance}`
|
||||
)
|
||||
|
||||
try {
|
||||
const jsonData = {
|
||||
id: randomUUID(),
|
||||
amount: 1,
|
||||
type: 101,
|
||||
attributes: {
|
||||
offerid: offerId
|
||||
},
|
||||
country: this.bot.userData.geoLocale
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'APP-REWARD',
|
||||
`Prepared activity payload | offerId=${offerId} | id=${jsonData.id} | amount=${jsonData.amount} | type=${jsonData.type} | country=${jsonData.country}`
|
||||
)
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||
'User-Agent':
|
||||
'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Rewards-Country': this.bot.userData.geoLocale,
|
||||
'X-Rewards-Language': 'en',
|
||||
'X-Rewards-ismobile': 'true'
|
||||
},
|
||||
data: JSON.stringify(jsonData)
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'APP-REWARD',
|
||||
`Sending activity request | offerId=${offerId} | url=${request.url}`
|
||||
)
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'APP-REWARD',
|
||||
`Received activity response | offerId=${offerId} | status=${response.status}`
|
||||
)
|
||||
|
||||
const newBalance = Number(response?.data?.response?.balance ?? this.oldBalance)
|
||||
this.gainedPoints = newBalance - this.oldBalance
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'APP-REWARD',
|
||||
`Balance delta after AppReward | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||
)
|
||||
|
||||
if (this.gainedPoints > 0) {
|
||||
this.bot.userData.currentPoints = newBalance
|
||||
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'APP-REWARD',
|
||||
`Completed AppReward | offerId=${offerId} | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`,
|
||||
'green'
|
||||
)
|
||||
} else {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'APP-REWARD',
|
||||
`Completed AppReward with no points | offerId=${offerId} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`
|
||||
)
|
||||
}
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'APP-REWARD', `Waiting after AppReward | offerId=${offerId}`)
|
||||
|
||||
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 10000))
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'APP-REWARD',
|
||||
`Finished AppReward | offerId=${offerId} | finalBalance=${this.bot.userData.currentPoints}`
|
||||
)
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'APP-REWARD',
|
||||
`Error in doAppReward | offerId=${offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
161
src/functions/activities/app/DailyCheckIn.ts
Normal file
161
src/functions/activities/app/DailyCheckIn.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { Workers } from '../../Workers'
|
||||
|
||||
export class DailyCheckIn extends Workers {
|
||||
private gainedPoints: number = 0
|
||||
|
||||
private oldBalance: number = this.bot.userData.currentPoints
|
||||
|
||||
public async doDailyCheckIn() {
|
||||
if (!this.bot.accessToken) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
'Skipping: App access token not available, this activity requires it!'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.oldBalance = Number(this.bot.userData.currentPoints ?? 0)
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Starting Daily Check-In | geo=${this.bot.userData.geoLocale} | currentPoints=${this.oldBalance}`
|
||||
)
|
||||
|
||||
try {
|
||||
// Try type 101 first
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DAILY-CHECK-IN', 'Attempting Daily Check-In | type=101')
|
||||
|
||||
let response = await this.submitDaily(101) // Try using 101 (EU Variant?)
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Received Daily Check-In response | type=101 | status=${response?.status ?? 'unknown'}`
|
||||
)
|
||||
|
||||
let newBalance = Number(response?.data?.response?.balance ?? this.oldBalance)
|
||||
this.gainedPoints = newBalance - this.oldBalance
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Balance delta after Daily Check-In | type=101 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||
)
|
||||
|
||||
if (this.gainedPoints > 0) {
|
||||
this.bot.userData.currentPoints = newBalance
|
||||
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Completed Daily Check-In | type=101 | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`,
|
||||
'green'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`No points gained with type=101 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | retryingWithType=103`
|
||||
)
|
||||
|
||||
// Fallback to type 103
|
||||
this.bot.logger.debug(this.bot.isMobile, 'DAILY-CHECK-IN', 'Attempting Daily Check-In | type=103')
|
||||
|
||||
response = await this.submitDaily(103) // Try using 103 (USA Variant?)
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Received Daily Check-In response | type=103 | status=${response?.status ?? 'unknown'}`
|
||||
)
|
||||
|
||||
newBalance = Number(response?.data?.response?.balance ?? this.oldBalance)
|
||||
this.gainedPoints = newBalance - this.oldBalance
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Balance delta after Daily Check-In | type=103 | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||
)
|
||||
|
||||
if (this.gainedPoints > 0) {
|
||||
this.bot.userData.currentPoints = newBalance
|
||||
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Completed Daily Check-In | type=103 | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`,
|
||||
'green'
|
||||
)
|
||||
} else {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Daily Check-In completed but no points gained | typesTried=101,103 | oldBalance=${this.oldBalance} | finalBalance=${newBalance}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Error during Daily Check-In | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async submitDaily(type: number) {
|
||||
try {
|
||||
const jsonData = {
|
||||
id: randomUUID(),
|
||||
amount: 1,
|
||||
type: type,
|
||||
attributes: {
|
||||
offerid: 'Gamification_Sapphire_DailyCheckIn'
|
||||
},
|
||||
country: this.bot.userData.geoLocale
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Preparing Daily Check-In payload | type=${type} | id=${jsonData.id} | amount=${jsonData.amount} | country=${jsonData.country}`
|
||||
)
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||
'User-Agent':
|
||||
'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Rewards-Country': this.bot.userData.geoLocale,
|
||||
'X-Rewards-Language': 'en',
|
||||
'X-Rewards-ismobile': 'true'
|
||||
},
|
||||
data: JSON.stringify(jsonData)
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Sending Daily Check-In request | type=${type} | url=${request.url}`
|
||||
)
|
||||
|
||||
return this.bot.axios.request(request)
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'DAILY-CHECK-IN',
|
||||
`Error in submitDaily | type=${type} | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
131
src/functions/activities/app/ReadToEarn.ts
Normal file
131
src/functions/activities/app/ReadToEarn.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import { randomBytes } from 'crypto'
|
||||
import { Workers } from '../../Workers'
|
||||
|
||||
export class ReadToEarn extends Workers {
|
||||
public async doReadToEarn() {
|
||||
if (!this.bot.accessToken) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'READ-TO-EARN',
|
||||
'Skipping: App access token not available, this activity requires it!'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const delayMin = this.bot.config.searchSettings.readDelay.min
|
||||
const delayMax = this.bot.config.searchSettings.readDelay.max
|
||||
const startBalance = Number(this.bot.userData.currentPoints ?? 0)
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'READ-TO-EARN',
|
||||
`Starting Read to Earn | geo=${this.bot.userData.geoLocale} | delayRange=${delayMin}-${delayMax} | currentPoints=${startBalance}`
|
||||
)
|
||||
|
||||
try {
|
||||
const jsonData = {
|
||||
amount: 1,
|
||||
id: '1',
|
||||
type: 101,
|
||||
attributes: {
|
||||
offerid: 'ENUS_readarticle3_30points'
|
||||
},
|
||||
country: this.bot.userData.geoLocale
|
||||
}
|
||||
|
||||
const articleCount = 10
|
||||
let totalGained = 0
|
||||
let articlesRead = 0
|
||||
let oldBalance = startBalance
|
||||
|
||||
for (let i = 0; i < articleCount; ++i) {
|
||||
jsonData.id = randomBytes(64).toString('hex')
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'READ-TO-EARN',
|
||||
`Submitting Read to Earn activity | article=${i + 1}/${articleCount} | id=${jsonData.id} | country=${jsonData.country}`
|
||||
)
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://prod.rewardsplatform.microsoft.com/dapi/me/activities',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.bot.accessToken}`,
|
||||
'User-Agent':
|
||||
'Bing/32.5.431027001 (com.microsoft.bing; build:431027001; iOS 17.6.1) Alamofire/5.10.2',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Rewards-Country': this.bot.userData.geoLocale,
|
||||
'X-Rewards-Language': 'en',
|
||||
'X-Rewards-ismobile': 'true'
|
||||
},
|
||||
data: JSON.stringify(jsonData)
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'READ-TO-EARN',
|
||||
`Received Read to Earn response | article=${i + 1}/${articleCount} | status=${response?.status ?? 'unknown'}`
|
||||
)
|
||||
|
||||
const newBalance = Number(response?.data?.response?.balance ?? oldBalance)
|
||||
const gainedPoints = newBalance - oldBalance
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'READ-TO-EARN',
|
||||
`Balance delta after article | article=${i + 1}/${articleCount} | oldBalance=${oldBalance} | newBalance=${newBalance} | gainedPoints=${gainedPoints}`
|
||||
)
|
||||
|
||||
if (gainedPoints <= 0) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'READ-TO-EARN',
|
||||
`No points gained, stopping Read to Earn | article=${i + 1}/${articleCount} | status=${response.status} | oldBalance=${oldBalance} | newBalance=${newBalance}`
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// Update point tracking
|
||||
this.bot.userData.currentPoints = newBalance
|
||||
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints
|
||||
totalGained += gainedPoints
|
||||
articlesRead = i + 1
|
||||
oldBalance = newBalance
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'READ-TO-EARN',
|
||||
`Read article ${i + 1}/${articleCount} | status=${response.status} | gainedPoints=${gainedPoints} | newBalance=${newBalance}`,
|
||||
'green'
|
||||
)
|
||||
|
||||
// Wait random delay between articles
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'READ-TO-EARN',
|
||||
`Waiting between articles | article=${i + 1}/${articleCount} | delayRange=${delayMin}-${delayMax}`
|
||||
)
|
||||
|
||||
await this.bot.utils.wait(this.bot.utils.randomDelay(delayMin, delayMax))
|
||||
}
|
||||
|
||||
const finalBalance = Number(this.bot.userData.currentPoints ?? startBalance)
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'READ-TO-EARN',
|
||||
`Completed Read to Earn | articlesRead=${articlesRead} | totalGained=${totalGained} | startBalance=${startBalance} | finalBalance=${finalBalance}`
|
||||
)
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'READ-TO-EARN',
|
||||
`Error during Read to Earn | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
431
src/functions/activities/browser/Search.ts
Normal file
431
src/functions/activities/browser/Search.ts
Normal file
@@ -0,0 +1,431 @@
|
||||
import type { Page } from 'patchright'
|
||||
import type { Counters, DashboardData } from '../../../interface/DashboardData'
|
||||
|
||||
import { QueryCore } from '../../QueryEngine'
|
||||
import { Workers } from '../../Workers'
|
||||
|
||||
export class Search extends Workers {
|
||||
private bingHome = 'https://bing.com'
|
||||
private searchPageURL = ''
|
||||
private searchCount = 0
|
||||
|
||||
public async doSearch(data: DashboardData, page: Page, isMobile: boolean): Promise<number> {
|
||||
const startBalance = Number(this.bot.userData.currentPoints ?? 0)
|
||||
|
||||
this.bot.logger.info(isMobile, 'SEARCH-BING', `Starting Bing searches | currentPoints=${startBalance}`)
|
||||
|
||||
let totalGainedPoints = 0
|
||||
|
||||
try {
|
||||
let searchCounters: Counters = await this.bot.browser.func.getSearchPoints()
|
||||
const missingPoints = this.bot.browser.func.missingSearchPoints(searchCounters, isMobile)
|
||||
let missingPointsTotal = missingPoints.totalPoints
|
||||
|
||||
this.bot.logger.debug(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Initial search counters | mobile=${missingPoints.mobilePoints} | desktop=${missingPoints.desktopPoints} | edge=${missingPoints.edgePoints}`
|
||||
)
|
||||
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Search points remaining | Edge=${missingPoints.edgePoints} | Desktop=${missingPoints.desktopPoints} | Mobile=${missingPoints.mobilePoints}`
|
||||
)
|
||||
|
||||
const queryCore = new QueryCore(this.bot)
|
||||
const locale = (this.bot.userData.geoLocale ?? 'US').toUpperCase()
|
||||
const langCode = (this.bot.userData.langCode ?? 'en').toLowerCase()
|
||||
|
||||
this.bot.logger.debug(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Resolving search queries via QueryCore | locale=${locale} | lang=${langCode} | related=true`
|
||||
)
|
||||
|
||||
let queries = await queryCore.queryManager({
|
||||
shuffle: true,
|
||||
related: true,
|
||||
langCode,
|
||||
geoLocale: locale,
|
||||
sourceOrder: ['google', 'wikipedia', 'reddit', 'local']
|
||||
})
|
||||
|
||||
queries = [...new Set(queries.map(q => q.trim()).filter(Boolean))]
|
||||
|
||||
this.bot.logger.debug(isMobile, 'SEARCH-BING', `Query pool ready | count=${queries.length}`)
|
||||
|
||||
// Go to bing
|
||||
const targetUrl = this.searchPageURL ? this.searchPageURL : this.bingHome
|
||||
this.bot.logger.debug(isMobile, 'SEARCH-BING', `Navigating to search page | url=${targetUrl}`)
|
||||
|
||||
await page.goto(targetUrl)
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
|
||||
let stagnantLoop = 0
|
||||
const stagnantLoopMax = 10
|
||||
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
const query = queries[i] as string
|
||||
|
||||
searchCounters = await this.bingSearch(page, query, isMobile)
|
||||
const newMissingPoints = this.bot.browser.func.missingSearchPoints(searchCounters, isMobile)
|
||||
const newMissingPointsTotal = newMissingPoints.totalPoints
|
||||
|
||||
const rawGained = missingPointsTotal - newMissingPointsTotal
|
||||
const gainedPoints = Math.max(0, rawGained)
|
||||
|
||||
if (gainedPoints === 0) {
|
||||
stagnantLoop++
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`No points gained ${stagnantLoop}/${stagnantLoopMax} | query="${query}" | remaining=${newMissingPointsTotal}`
|
||||
)
|
||||
} else {
|
||||
stagnantLoop = 0
|
||||
|
||||
const newBalance = Number(this.bot.userData.currentPoints ?? 0) + gainedPoints
|
||||
this.bot.userData.currentPoints = newBalance
|
||||
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints
|
||||
|
||||
totalGainedPoints += gainedPoints
|
||||
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`gainedPoints=${gainedPoints} points | query="${query}" | remaining=${newMissingPointsTotal}`,
|
||||
'green'
|
||||
)
|
||||
}
|
||||
|
||||
missingPointsTotal = newMissingPointsTotal
|
||||
|
||||
if (missingPointsTotal === 0) {
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
'All required search points earned, stopping main search loop'
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
if (stagnantLoop > stagnantLoopMax) {
|
||||
this.bot.logger.warn(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Search did not gain points for ${stagnantLoopMax} iterations, aborting main search loop`
|
||||
)
|
||||
stagnantLoop = 0
|
||||
break
|
||||
}
|
||||
|
||||
const remainingQueries = queries.length - (i + 1)
|
||||
const minBuffer = 20
|
||||
if (missingPointsTotal > 0 && remainingQueries < minBuffer) {
|
||||
this.bot.logger.warn(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Low query buffer while still missing points, regenerating | remainingQueries=${remainingQueries} | missing=${missingPointsTotal}`
|
||||
)
|
||||
|
||||
const extra = await queryCore.queryManager({
|
||||
shuffle: true,
|
||||
related: true,
|
||||
langCode,
|
||||
geoLocale: locale,
|
||||
sourceOrder: this.bot.config.searchSettings.queryEngines
|
||||
})
|
||||
|
||||
const merged = [...queries, ...extra].map(q => q.trim()).filter(Boolean)
|
||||
queries = [...new Set(merged)]
|
||||
queries = this.bot.utils.shuffleArray(queries)
|
||||
|
||||
this.bot.logger.debug(isMobile, 'SEARCH-BING', `Query pool regenerated | count=${queries.length}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (missingPointsTotal > 0) {
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Search completed but still missing points, continuing with regenerated queries | remaining=${missingPointsTotal}`
|
||||
)
|
||||
|
||||
let stagnantLoop = 0
|
||||
const stagnantLoopMax = 5
|
||||
|
||||
while (missingPointsTotal > 0) {
|
||||
const extra = await queryCore.queryManager({
|
||||
shuffle: true,
|
||||
related: true,
|
||||
langCode,
|
||||
geoLocale: locale,
|
||||
sourceOrder: this.bot.config.searchSettings.queryEngines
|
||||
})
|
||||
|
||||
const merged = [...queries, ...extra].map(q => q.trim()).filter(Boolean)
|
||||
const newPool = [...new Set(merged)]
|
||||
queries = this.bot.utils.shuffleArray(newPool)
|
||||
|
||||
this.bot.logger.debug(
|
||||
isMobile,
|
||||
'SEARCH-BING-EXTRA',
|
||||
`New query pool generated | count=${queries.length}`
|
||||
)
|
||||
|
||||
for (const query of queries) {
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING-EXTRA',
|
||||
`Extra search | remaining=${missingPointsTotal} | query="${query}"`
|
||||
)
|
||||
|
||||
searchCounters = await this.bingSearch(page, query, isMobile)
|
||||
const newMissingPoints = this.bot.browser.func.missingSearchPoints(searchCounters, isMobile)
|
||||
const newMissingPointsTotal = newMissingPoints.totalPoints
|
||||
|
||||
const rawGained = missingPointsTotal - newMissingPointsTotal
|
||||
const gainedPoints = Math.max(0, rawGained)
|
||||
|
||||
if (gainedPoints === 0) {
|
||||
stagnantLoop++
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING-EXTRA',
|
||||
`No points gained ${stagnantLoop}/${stagnantLoopMax} | query="${query}" | remaining=${newMissingPointsTotal}`
|
||||
)
|
||||
} else {
|
||||
stagnantLoop = 0
|
||||
|
||||
const newBalance = Number(this.bot.userData.currentPoints ?? 0) + gainedPoints
|
||||
this.bot.userData.currentPoints = newBalance
|
||||
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + gainedPoints
|
||||
|
||||
totalGainedPoints += gainedPoints
|
||||
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING-EXTRA',
|
||||
`gainedPoints=${gainedPoints} points | query="${query}" | remaining=${newMissingPointsTotal}`,
|
||||
'green'
|
||||
)
|
||||
}
|
||||
|
||||
missingPointsTotal = newMissingPointsTotal
|
||||
|
||||
if (missingPointsTotal === 0) {
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING-EXTRA',
|
||||
'All required search points earned during extra searches'
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
if (stagnantLoop > stagnantLoopMax) {
|
||||
this.bot.logger.warn(
|
||||
isMobile,
|
||||
'SEARCH-BING-EXTRA',
|
||||
`Search did not gain points for ${stagnantLoopMax} iterations, aborting extra searches`
|
||||
)
|
||||
const finalBalance = Number(this.bot.userData.currentPoints ?? startBalance)
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Aborted extra searches | startBalance=${startBalance} | finalBalance=${finalBalance}`
|
||||
)
|
||||
return totalGainedPoints
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const finalBalance = Number(this.bot.userData.currentPoints ?? startBalance)
|
||||
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Completed Bing searches | startBalance=${startBalance} | newBalance=${finalBalance}`
|
||||
)
|
||||
|
||||
return totalGainedPoints
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Error in doSearch | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return totalGainedPoints
|
||||
}
|
||||
}
|
||||
|
||||
private async bingSearch(searchPage: Page, query: string, isMobile: boolean) {
|
||||
const maxAttempts = 5
|
||||
const refreshThreshold = 10 // Page gets sluggish after x searches?
|
||||
|
||||
this.searchCount++
|
||||
|
||||
if (this.searchCount % refreshThreshold === 0) {
|
||||
this.bot.logger.info(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Returning to home page to clear accumulated page context | count=${this.searchCount} | threshold=${refreshThreshold}`
|
||||
)
|
||||
|
||||
this.bot.logger.debug(isMobile, 'SEARCH-BING', `Returning home to refresh state | url=${this.bingHome}`)
|
||||
|
||||
await searchPage.goto(this.bingHome)
|
||||
await searchPage.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
|
||||
await this.bot.browser.utils.tryDismissAllMessages(searchPage)
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Starting bingSearch | query="${query}" | maxAttempts=${maxAttempts} | searchCount=${this.searchCount} | refreshEvery=${refreshThreshold} | scrollRandomResults=${this.bot.config.searchSettings.scrollRandomResults} | clickRandomResults=${this.bot.config.searchSettings.clickRandomResults}`
|
||||
)
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
try {
|
||||
const searchBar = '#sb_form_q'
|
||||
const searchBox = searchPage.locator(searchBar)
|
||||
|
||||
await searchPage.evaluate(() => {
|
||||
window.scrollTo({ left: 0, top: 0, behavior: 'auto' })
|
||||
})
|
||||
|
||||
await searchPage.keyboard.press('Home')
|
||||
await searchBox.waitFor({ state: 'visible', timeout: 15000 })
|
||||
|
||||
await this.bot.utils.wait(1000)
|
||||
await this.bot.browser.utils.ghostClick(searchPage, searchBar, { clickCount: 3 })
|
||||
await searchBox.fill('')
|
||||
|
||||
await searchPage.keyboard.type(query, { delay: 50 })
|
||||
await searchPage.keyboard.press('Enter')
|
||||
|
||||
this.bot.logger.debug(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Submitted query to Bing | attempt=${i + 1}/${maxAttempts} | query="${query}"`
|
||||
)
|
||||
|
||||
await this.bot.utils.wait(3000)
|
||||
|
||||
if (this.bot.config.searchSettings.scrollRandomResults) {
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.randomScroll(searchPage, isMobile)
|
||||
}
|
||||
|
||||
if (this.bot.config.searchSettings.clickRandomResults) {
|
||||
await this.bot.utils.wait(2000)
|
||||
await this.clickRandomLink(searchPage, isMobile)
|
||||
}
|
||||
|
||||
await this.bot.utils.wait(
|
||||
this.bot.utils.randomDelay(
|
||||
this.bot.config.searchSettings.searchDelay.min,
|
||||
this.bot.config.searchSettings.searchDelay.max
|
||||
)
|
||||
)
|
||||
|
||||
const counters = await this.bot.browser.func.getSearchPoints()
|
||||
|
||||
this.bot.logger.debug(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Search counters after query | attempt=${i + 1}/${maxAttempts} | query="${query}"`
|
||||
)
|
||||
|
||||
return counters
|
||||
} catch (error) {
|
||||
if (i >= 5) {
|
||||
this.bot.logger.error(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Failed after 5 retries | query="${query}" | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
this.bot.logger.error(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Search attempt failed | attempt=${i + 1}/${maxAttempts} | query="${query}" | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
|
||||
this.bot.logger.warn(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Retrying search | attempt=${i + 1}/${maxAttempts} | query="${query}"`
|
||||
)
|
||||
|
||||
await this.bot.utils.wait(2000)
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.logger.debug(
|
||||
isMobile,
|
||||
'SEARCH-BING',
|
||||
`Returning current search counters after failed retries | query="${query}"`
|
||||
)
|
||||
|
||||
return await this.bot.browser.func.getSearchPoints()
|
||||
}
|
||||
|
||||
private async randomScroll(page: Page, isMobile: boolean) {
|
||||
try {
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight)
|
||||
const totalHeight = await page.evaluate(() => document.body.scrollHeight)
|
||||
const randomScrollPosition = Math.floor(Math.random() * (totalHeight - viewportHeight))
|
||||
|
||||
this.bot.logger.debug(
|
||||
isMobile,
|
||||
'SEARCH-RANDOM-SCROLL',
|
||||
`Random scroll | viewportHeight=${viewportHeight} | totalHeight=${totalHeight} | scrollPos=${randomScrollPosition}`
|
||||
)
|
||||
|
||||
await page.evaluate((scrollPos: number) => {
|
||||
window.scrollTo({ left: 0, top: scrollPos, behavior: 'auto' })
|
||||
}, randomScrollPosition)
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
isMobile,
|
||||
'SEARCH-RANDOM-SCROLL',
|
||||
`An error occurred during random scroll | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async clickRandomLink(page: Page, isMobile: boolean) {
|
||||
try {
|
||||
this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', 'Attempting to click a random search result link')
|
||||
|
||||
const searchPageUrl = page.url()
|
||||
|
||||
await this.bot.browser.utils.ghostClick(page, '#b_results .b_algo h2')
|
||||
await this.bot.utils.wait(this.bot.config.searchSettings.searchResultVisitTime)
|
||||
|
||||
if (isMobile) {
|
||||
await page.goto(searchPageUrl)
|
||||
this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', 'Navigated back to search page')
|
||||
} else {
|
||||
const newTab = await this.bot.browser.utils.getLatestTab(page)
|
||||
const newTabUrl = newTab.url()
|
||||
|
||||
this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', `Visited result tab | url=${newTabUrl}`)
|
||||
|
||||
await this.bot.browser.utils.closeTabs(newTab)
|
||||
this.bot.logger.debug(isMobile, 'SEARCH-RANDOM-CLICK', 'Closed result tab')
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
isMobile,
|
||||
'SEARCH-RANDOM-CLICK',
|
||||
`An error occurred during random click | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
330
src/functions/activities/browser/SearchOnBing.ts
Normal file
330
src/functions/activities/browser/SearchOnBing.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import type { Page } from 'patchright'
|
||||
import * as fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { Workers } from '../../Workers'
|
||||
import { QueryCore } from '../../QueryEngine'
|
||||
|
||||
import type { BasePromotion } from '../../../interface/DashboardData'
|
||||
|
||||
export class SearchOnBing extends Workers {
|
||||
private bingHome = 'https://bing.com'
|
||||
|
||||
private cookieHeader: string = ''
|
||||
|
||||
private fingerprintHeader: { [x: string]: string } = {}
|
||||
|
||||
private gainedPoints: number = 0
|
||||
|
||||
private success: boolean = false
|
||||
|
||||
private oldBalance: number = this.bot.userData.currentPoints
|
||||
|
||||
public async doSearchOnBing(promotion: BasePromotion, page: Page) {
|
||||
const offerId = promotion.offerId
|
||||
this.oldBalance = Number(this.bot.userData.currentPoints ?? 0)
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING',
|
||||
`Starting SearchOnBing | offerId=${offerId} | title="${promotion.title}" | currentPoints=${this.oldBalance}`
|
||||
)
|
||||
|
||||
try {
|
||||
this.cookieHeader = (this.bot.isMobile ? this.bot.cookies.mobile : this.bot.cookies.desktop)
|
||||
.map((c: { name: string; value: string }) => `${c.name}=${c.value}`)
|
||||
.join('; ')
|
||||
|
||||
const fingerprintHeaders = { ...this.bot.fingerprint.headers }
|
||||
delete fingerprintHeaders['Cookie']
|
||||
delete fingerprintHeaders['cookie']
|
||||
this.fingerprintHeader = fingerprintHeaders
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING',
|
||||
`Prepared headers for SearchOnBing | offerId=${offerId} | cookieLength=${this.cookieHeader.length} | fingerprintHeaderKeys=${Object.keys(this.fingerprintHeader).length}`
|
||||
)
|
||||
|
||||
this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING', `Activating search task | offerId=${offerId}`)
|
||||
|
||||
const activated = await this.activateSearchTask(promotion)
|
||||
if (!activated) {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING',
|
||||
`Search activity couldn't be activated, aborting | offerId=${offerId}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Do the bing search here
|
||||
const queries = await this.getSearchQueries(promotion)
|
||||
|
||||
// Run through the queries
|
||||
await this.searchBing(page, queries)
|
||||
|
||||
if (this.success) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING',
|
||||
`Completed SearchOnBing | offerId=${offerId} | startBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}`
|
||||
)
|
||||
} else {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING',
|
||||
`Failed SearchOnBing | offerId=${offerId} | startBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING',
|
||||
`Error in doSearchOnBing | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async searchBing(page: Page, queries: string[]) {
|
||||
queries = [...new Set(queries)]
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-SEARCH',
|
||||
`Starting search loop | queriesCount=${queries.length} | oldBalance=${this.oldBalance}`
|
||||
)
|
||||
|
||||
let i = 0
|
||||
for (const query of queries) {
|
||||
try {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-SEARCH', `Processing query | query="${query}"`)
|
||||
|
||||
await this.bot.mainMobilePage.goto(this.bingHome)
|
||||
|
||||
// Wait until page loaded
|
||||
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => {})
|
||||
|
||||
await this.bot.browser.utils.tryDismissAllMessages(page)
|
||||
|
||||
const searchBar = '#sb_form_q'
|
||||
|
||||
const searchBox = page.locator(searchBar)
|
||||
await searchBox.waitFor({ state: 'attached', timeout: 15000 })
|
||||
|
||||
await this.bot.utils.wait(500)
|
||||
await this.bot.browser.utils.ghostClick(page, searchBar, { clickCount: 3 })
|
||||
await searchBox.fill('')
|
||||
|
||||
await page.keyboard.type(query, { delay: 50 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 7000))
|
||||
|
||||
// Check for point updates
|
||||
const newBalance = await this.bot.browser.func.getCurrentPoints()
|
||||
this.gainedPoints = newBalance - this.oldBalance
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-SEARCH',
|
||||
`Balance check after query | query="${query}" | oldBalance=${this.oldBalance} | newBalance=${newBalance} | gainedPoints=${this.gainedPoints}`
|
||||
)
|
||||
|
||||
if (this.gainedPoints > 0) {
|
||||
this.bot.userData.currentPoints = newBalance
|
||||
this.bot.userData.gainedPoints = (this.bot.userData.gainedPoints ?? 0) + this.gainedPoints
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-SEARCH',
|
||||
`SearchOnBing query completed | query="${query}" | gainedPoints=${this.gainedPoints} | oldBalance=${this.oldBalance} | newBalance=${newBalance}`,
|
||||
'green'
|
||||
)
|
||||
|
||||
this.success = true
|
||||
return
|
||||
} else {
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-SEARCH',
|
||||
`${++i}/${queries.length} | noPoints=1 | query="${query}"`
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-SEARCH',
|
||||
`Error during search loop | query="${query}" | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
} finally {
|
||||
await this.bot.utils.wait(this.bot.utils.randomDelay(5000, 15000))
|
||||
await page.goto(this.bot.config.baseURL, { timeout: 5000 }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
this.bot.logger.warn(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-SEARCH',
|
||||
`Finished all queries with no points gained | queriesTried=${queries.length} | oldBalance=${this.oldBalance} | finalBalance=${this.bot.userData.currentPoints}`
|
||||
)
|
||||
}
|
||||
|
||||
// The task needs to be activated before being able to complete it
|
||||
private async activateSearchTask(promotion: BasePromotion): Promise<boolean> {
|
||||
try {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-ACTIVATE',
|
||||
`Preparing activation request | offerId=${promotion.offerId} | hash=${promotion.hash}`
|
||||
)
|
||||
|
||||
const formData = new URLSearchParams({
|
||||
id: promotion.offerId,
|
||||
hash: promotion.hash,
|
||||
timeZone: '60',
|
||||
activityAmount: '1',
|
||||
dbs: '0',
|
||||
form: '',
|
||||
type: '',
|
||||
__RequestVerificationToken: this.bot.requestToken
|
||||
})
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
url: 'https://rewards.bing.com/api/reportactivity?X-Requested-With=XMLHttpRequest',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this.bot.fingerprint?.headers ?? {}),
|
||||
Cookie: this.cookieHeader,
|
||||
Referer: 'https://rewards.bing.com/',
|
||||
Origin: 'https://rewards.bing.com'
|
||||
},
|
||||
data: formData
|
||||
}
|
||||
|
||||
const response = await this.bot.axios.request(request)
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-ACTIVATE',
|
||||
`Successfully activated activity | status=${response.status} | offerId=${promotion.offerId}`
|
||||
)
|
||||
return true
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-ACTIVATE',
|
||||
`Activation failed | offerId=${promotion.offerId} | message=${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private async getSearchQueries(promotion: BasePromotion): Promise<string[]> {
|
||||
interface Queries {
|
||||
title: string
|
||||
queries: string[]
|
||||
}
|
||||
|
||||
let queries: Queries[] = []
|
||||
|
||||
try {
|
||||
if (this.bot.config.searchOnBingLocalQueries) {
|
||||
this.bot.logger.debug(this.bot.isMobile, 'SEARCH-ON-BING-QUERY', 'Using local queries config file')
|
||||
|
||||
const data = fs.readFileSync(path.join(__dirname, '../bing-search-activity-queries.json'), 'utf8')
|
||||
queries = JSON.parse(data)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-QUERY',
|
||||
`Loaded queries config | source=local | entries=${queries.length}`
|
||||
)
|
||||
} else {
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-QUERY',
|
||||
'Fetching queries config from remote repository'
|
||||
)
|
||||
|
||||
// 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/v3/src/functions/bing-search-activity-queries.json'
|
||||
})
|
||||
queries = response.data
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-QUERY',
|
||||
`Loaded queries config | source=remote | entries=${queries.length}`
|
||||
)
|
||||
}
|
||||
|
||||
const answers = queries.find(
|
||||
x => this.bot.utils.normalizeString(x.title) === this.bot.utils.normalizeString(promotion.title)
|
||||
)
|
||||
|
||||
if (answers && answers.queries.length > 0) {
|
||||
const answer = this.bot.utils.shuffleArray(answers.queries)
|
||||
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-QUERY',
|
||||
`Found answers for activity title | source=${this.bot.config.searchOnBingLocalQueries ? 'local' : 'remote'} | title="${promotion.title}" | answersCount=${answer.length} | firstQuery="${answer[0]}"`
|
||||
)
|
||||
|
||||
return answer
|
||||
} else {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-QUERY',
|
||||
`No matching title in queries config | source=${this.bot.config.searchOnBingLocalQueries ? 'local' : 'remote'} | title="${promotion.title}"`
|
||||
)
|
||||
|
||||
const queryCore = new QueryCore(this.bot)
|
||||
|
||||
const promotionDescription = promotion.description.toLowerCase().trim()
|
||||
const queryDescription = promotionDescription.replace('search on bing', '').trim()
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-QUERY',
|
||||
`Requesting Bing suggestions | queryDescription="${queryDescription}"`
|
||||
)
|
||||
|
||||
const bingSuggestions = await queryCore.getBingSuggestions(queryDescription)
|
||||
|
||||
this.bot.logger.debug(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-QUERY',
|
||||
`Bing suggestions result | count=${bingSuggestions.length} | title="${promotion.title}"`
|
||||
)
|
||||
|
||||
// If no suggestions found
|
||||
if (!bingSuggestions.length) {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-QUERY',
|
||||
`No suggestions found, falling back to activity title | title="${promotion.title}"`
|
||||
)
|
||||
return [promotion.title]
|
||||
} else {
|
||||
this.bot.logger.info(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-QUERY',
|
||||
`Using Bing suggestions as search queries | count=${bingSuggestions.length} | title="${promotion.title}"`
|
||||
)
|
||||
return bingSuggestions
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
this.bot.logger.error(
|
||||
this.bot.isMobile,
|
||||
'SEARCH-ON-BING-QUERY',
|
||||
`Error while resolving search queries | title="${promotion.title}" | message=${error instanceof Error ? error.message : String(error)} | fallback=promotionTitle`
|
||||
)
|
||||
return [promotion.title]
|
||||
}
|
||||
}
|
||||
}
|
||||
582
src/functions/bing-search-activity-queries.json
Normal file
582
src/functions/bing-search-activity-queries.json
Normal file
@@ -0,0 +1,582 @@
|
||||
[
|
||||
{
|
||||
"title": "Houses near you",
|
||||
"queries": [
|
||||
"Houses near me",
|
||||
"Homes for sale near me",
|
||||
"Apartments near me",
|
||||
"Real estate listings near me",
|
||||
"Zillow homes near me",
|
||||
"houses for rent near me"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Feeling symptoms?",
|
||||
"queries": [
|
||||
"Rash on forearm",
|
||||
"Stuffy nose",
|
||||
"Tickling cough",
|
||||
"sore throat remedies",
|
||||
"headache and nausea causes",
|
||||
"fever symptoms adults"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Get your shopping done faster",
|
||||
"queries": [
|
||||
"Buy PS5",
|
||||
"Buy Xbox",
|
||||
"Chair deals",
|
||||
"wireless mouse deals",
|
||||
"best gaming headset price",
|
||||
"laptop deals",
|
||||
"buy office chair",
|
||||
"SSD deals"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Translate anything",
|
||||
"queries": [
|
||||
"Translate welcome home to Korean",
|
||||
"Translate welcome home to Japanese",
|
||||
"Translate goodbye to Japanese",
|
||||
"Translate good morning to Spanish",
|
||||
"Translate thank you to French",
|
||||
"Translate see you later to Italian"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Search the lyrics of a song",
|
||||
"queries": [
|
||||
"Debarge rhythm of the night lyrics",
|
||||
"bohemian rhapsody lyrics",
|
||||
"hotel california lyrics",
|
||||
"blinding lights lyrics",
|
||||
"lose yourself lyrics",
|
||||
"smells like teen spirit lyrics"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Let's watch that movie again!",
|
||||
"queries": [
|
||||
"Alien movie",
|
||||
"Aliens movie",
|
||||
"Alien 3 movie",
|
||||
"Predator movie",
|
||||
"Terminator movie",
|
||||
"John Wick movie",
|
||||
"Interstellar movie",
|
||||
"The Matrix movie"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Plan a quick getaway",
|
||||
"queries": [
|
||||
"Flights Amsterdam to Tokyo",
|
||||
"Flights New York to Tokyo",
|
||||
"cheap flights to paris",
|
||||
"flights amsterdam to rome",
|
||||
"last minute flight deals",
|
||||
"direct flights from amsterdam",
|
||||
"weekend getaway europe",
|
||||
"best time to visit tokyo"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Discover open job roles",
|
||||
"queries": [
|
||||
"jobs at Microsoft",
|
||||
"Microsoft Job Openings",
|
||||
"Jobs near me",
|
||||
"jobs at Boeing worked",
|
||||
"software engineer jobs near me",
|
||||
"remote developer jobs",
|
||||
"IT jobs netherlands",
|
||||
"customer support jobs near me"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "You can track your package",
|
||||
"queries": [
|
||||
"USPS tracking",
|
||||
"UPS tracking",
|
||||
"DHL tracking",
|
||||
"FedEx tracking",
|
||||
"track my package",
|
||||
"international package tracking"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Find somewhere new to explore",
|
||||
"queries": [
|
||||
"Directions to Berlin",
|
||||
"Directions to Tokyo",
|
||||
"Directions to New York",
|
||||
"things to do in berlin",
|
||||
"tourist attractions tokyo",
|
||||
"best places to visit in new york",
|
||||
"hidden gems near me",
|
||||
"day trips near me"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Too tired to cook tonight?",
|
||||
"queries": [
|
||||
"KFC near me",
|
||||
"Burger King near me",
|
||||
"McDonalds near me",
|
||||
"pizza delivery near me",
|
||||
"restaurants open now",
|
||||
"best takeout near me",
|
||||
"quick dinner ideas",
|
||||
"easy dinner recipes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Quickly convert your money",
|
||||
"queries": [
|
||||
"convert 250 USD to yen",
|
||||
"convert 500 USD to yen",
|
||||
"usd to eur",
|
||||
"gbp to eur",
|
||||
"eur to jpy",
|
||||
"currency converter",
|
||||
"exchange rate today",
|
||||
"1000 yen to euro"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Learn to cook a new recipe",
|
||||
"queries": [
|
||||
"How to cook ratatouille",
|
||||
"How to cook lasagna",
|
||||
"easy pasta recipe",
|
||||
"how to make pancakes",
|
||||
"how to make fried rice",
|
||||
"simple chicken recipe"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Find places to stay!",
|
||||
"queries": [
|
||||
"Hotels Berlin Germany",
|
||||
"Hotels Amsterdam Netherlands",
|
||||
"hotels in paris",
|
||||
"best hotels in tokyo",
|
||||
"cheap hotels london",
|
||||
"places to stay in barcelona",
|
||||
"hotel deals",
|
||||
"booking hotels near me"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "How's the economy?",
|
||||
"queries": [
|
||||
"sp 500",
|
||||
"nasdaq",
|
||||
"dow jones today",
|
||||
"inflation rate europe",
|
||||
"interest rates today",
|
||||
"stock market today",
|
||||
"economic news",
|
||||
"recession forecast"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Who won?",
|
||||
"queries": [
|
||||
"braves score",
|
||||
"champions league results",
|
||||
"premier league results",
|
||||
"nba score",
|
||||
"formula 1 winner",
|
||||
"latest football scores",
|
||||
"ucl final winner",
|
||||
"world cup final result"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Gaming time",
|
||||
"queries": [
|
||||
"Overwatch video game",
|
||||
"Call of duty video game",
|
||||
"best games 2025",
|
||||
"top xbox games",
|
||||
"popular steam games",
|
||||
"new pc games",
|
||||
"game reviews",
|
||||
"best co-op games"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Expand your vocabulary",
|
||||
"queries": [
|
||||
"definition definition",
|
||||
"meaning of serendipity",
|
||||
"define nostalgia",
|
||||
"synonym for happy",
|
||||
"define eloquent",
|
||||
"what does epiphany mean",
|
||||
"word of the day",
|
||||
"define immaculate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "What time is it?",
|
||||
"queries": [
|
||||
"Japan time",
|
||||
"New York time",
|
||||
"time in london",
|
||||
"time in tokyo",
|
||||
"current time in amsterdam",
|
||||
"time in los angeles"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Find deals on Bing",
|
||||
"queries": [
|
||||
"best laptop deals",
|
||||
"tech deals today",
|
||||
"wireless earbuds deals",
|
||||
"gaming chair deals",
|
||||
"discount codes electronics",
|
||||
"best amazon deals today",
|
||||
"smartphone deals",
|
||||
"ssd deals"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Prepare for the weather",
|
||||
"queries": [
|
||||
"weather tomorrow",
|
||||
"weekly weather forecast",
|
||||
"rain forecast today",
|
||||
"weather in amsterdam",
|
||||
"storm forecast europe",
|
||||
"uv index today",
|
||||
"temperature this weekend",
|
||||
"snow forecast"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Track your delivery",
|
||||
"queries": [
|
||||
"track my package",
|
||||
"postnl track and trace",
|
||||
"dhl parcel tracking",
|
||||
"ups tracking",
|
||||
"fedex tracking",
|
||||
"usps tracking",
|
||||
"parcel tracking",
|
||||
"international package tracking"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Explore a new spot today",
|
||||
"queries": [
|
||||
"places to visit near me",
|
||||
"things to do near me",
|
||||
"hidden gems netherlands",
|
||||
"best museums near me",
|
||||
"parks near me",
|
||||
"tourist attractions nearby",
|
||||
"best cafes near me",
|
||||
"day trip ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Maisons près de chez vous",
|
||||
"queries": [
|
||||
"Maisons près de chez moi",
|
||||
"Maisons à vendre près de chez moi",
|
||||
"Appartements près de chez moi",
|
||||
"Annonces immobilières près de chez moi",
|
||||
"Maisons à louer près de chez moi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Vous ressentez des symptômes ?",
|
||||
"queries": [
|
||||
"Éruption cutanée sur l'avant-bras",
|
||||
"Nez bouché",
|
||||
"Toux chatouilleuse",
|
||||
"mal de gorge remèdes",
|
||||
"maux de tête causes",
|
||||
"symptômes de la grippe"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Faites vos achats plus vite",
|
||||
"queries": [
|
||||
"Acheter une PS5",
|
||||
"Acheter une Xbox",
|
||||
"Offres sur les chaises",
|
||||
"offres ordinateur portable",
|
||||
"meilleures offres casque",
|
||||
"acheter souris sans fil",
|
||||
"promotions ssd",
|
||||
"bons plans tech"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Traduisez tout !",
|
||||
"queries": [
|
||||
"Traduction bienvenue à la maison en coréen",
|
||||
"Traduction bienvenue à la maison en japonais",
|
||||
"Traduction au revoir en japonais",
|
||||
"Traduire bonjour en espagnol",
|
||||
"Traduire merci en anglais",
|
||||
"Traduire à plus tard en italien"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Rechercher paroles de chanson",
|
||||
"queries": [
|
||||
"Paroles de Debarge rhythm of the night",
|
||||
"paroles bohemian rhapsody",
|
||||
"paroles hotel california",
|
||||
"paroles blinding lights",
|
||||
"paroles lose yourself",
|
||||
"paroles smells like teen spirit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Et si nous regardions ce film une nouvelle fois?",
|
||||
"queries": [
|
||||
"Alien film",
|
||||
"Film Aliens",
|
||||
"Film Alien 3",
|
||||
"Film Predator",
|
||||
"Film Terminator",
|
||||
"Film John Wick",
|
||||
"Film Interstellar",
|
||||
"Film Matrix"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Planifiez une petite escapade",
|
||||
"queries": [
|
||||
"Vols Amsterdam-Tokyo",
|
||||
"Vols New York-Tokyo",
|
||||
"vols pas chers paris",
|
||||
"vols amsterdam rome",
|
||||
"offres vols dernière minute",
|
||||
"week-end en europe",
|
||||
"vols directs depuis amsterdam"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Consulter postes à pourvoir",
|
||||
"queries": [
|
||||
"emplois chez Microsoft",
|
||||
"Offres d'emploi Microsoft",
|
||||
"Emplois près de chez moi",
|
||||
"emplois chez Boeing",
|
||||
"emplois développeur à distance",
|
||||
"emplois informatique pays-bas",
|
||||
"offres d'emploi près de chez moi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Vous pouvez suivre votre colis",
|
||||
"queries": [
|
||||
"Suivi Chronopost",
|
||||
"suivi colis",
|
||||
"suivi DHL",
|
||||
"suivi UPS",
|
||||
"suivi FedEx",
|
||||
"suivi international colis"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Trouver un endroit à découvrir",
|
||||
"queries": [
|
||||
"Itinéraire vers Berlin",
|
||||
"Itinéraire vers Tokyo",
|
||||
"Itinéraire vers New York",
|
||||
"que faire à berlin",
|
||||
"attractions tokyo",
|
||||
"meilleurs endroits à visiter à new york",
|
||||
"endroits à visiter près de chez moi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"livraison pizza près de chez moi",
|
||||
"restaurants ouverts maintenant",
|
||||
"idées dîner rapide",
|
||||
"quoi manger ce soir"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Convertissez rapidement votre argent",
|
||||
"queries": [
|
||||
"convertir 250 EUR en yen",
|
||||
"convertir 500 EUR en yen",
|
||||
"usd en eur",
|
||||
"gbp en eur",
|
||||
"eur en jpy",
|
||||
"convertisseur de devises",
|
||||
"taux de change aujourd'hui",
|
||||
"1000 yen en euro"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Apprenez à cuisiner une nouvelle recette",
|
||||
"queries": [
|
||||
"Comment faire cuire la ratatouille",
|
||||
"Comment faire cuire les lasagnes",
|
||||
"recette pâtes facile",
|
||||
"comment faire des crêpes",
|
||||
"recette riz sauté",
|
||||
"recette poulet simple"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Trouvez des emplacements pour rester!",
|
||||
"queries": [
|
||||
"Hôtels Berlin Allemagne",
|
||||
"Hôtels Amsterdam Pays-Bas",
|
||||
"hôtels paris",
|
||||
"meilleurs hôtels tokyo",
|
||||
"hôtels pas chers londres",
|
||||
"hébergement barcelone",
|
||||
"offres hôtels",
|
||||
"hôtels près de chez moi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Comment se porte l'économie ?",
|
||||
"queries": [
|
||||
"CAC 40",
|
||||
"indice dax",
|
||||
"dow jones aujourd'hui",
|
||||
"inflation europe",
|
||||
"taux d'intérêt aujourd'hui",
|
||||
"marché boursier aujourd'hui",
|
||||
"actualités économie",
|
||||
"prévisions récession"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Qui a gagné ?",
|
||||
"queries": [
|
||||
"score du Paris Saint-Germain",
|
||||
"résultats ligue des champions",
|
||||
"résultats premier league",
|
||||
"score nba",
|
||||
"vainqueur formule 1",
|
||||
"derniers scores football",
|
||||
"vainqueur finale ldc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Temps de jeu",
|
||||
"queries": [
|
||||
"Jeu vidéo Overwatch",
|
||||
"Jeu vidéo Call of Duty",
|
||||
"meilleurs jeux 2025",
|
||||
"top jeux xbox",
|
||||
"jeux steam populaires",
|
||||
"nouveaux jeux pc",
|
||||
"avis jeux vidéo",
|
||||
"meilleurs jeux coop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Enrichissez votre vocabulaire",
|
||||
"queries": [
|
||||
"definition definition",
|
||||
"signification sérendipité",
|
||||
"définir nostalgie",
|
||||
"synonyme heureux",
|
||||
"définir éloquent",
|
||||
"mot du jour",
|
||||
"que veut dire épiphanie"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Quelle heure est-il ?",
|
||||
"queries": [
|
||||
"Heure du Japon",
|
||||
"Heure de New York",
|
||||
"heure de londres",
|
||||
"heure de tokyo",
|
||||
"heure actuelle amsterdam",
|
||||
"heure de los angeles"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Vérifier la météo",
|
||||
"queries": [
|
||||
"Météo de Paris",
|
||||
"Météo de la France",
|
||||
"météo demain",
|
||||
"prévisions météo semaine",
|
||||
"météo amsterdam",
|
||||
"risque de pluie aujourd'hui"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Tenez-vous informé des sujets d'actualité",
|
||||
"queries": [
|
||||
"Augmentation Impots",
|
||||
"Mort célébrité",
|
||||
"actualités france",
|
||||
"actualité internationale",
|
||||
"dernières nouvelles économie",
|
||||
"news technologie"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Préparez-vous pour la météo",
|
||||
"queries": [
|
||||
"météo demain",
|
||||
"prévisions météo semaine",
|
||||
"météo amsterdam",
|
||||
"risque de pluie aujourd'hui",
|
||||
"indice uv aujourd'hui",
|
||||
"température ce week-end",
|
||||
"alerte tempête"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Suivez votre livraison",
|
||||
"queries": [
|
||||
"suivi colis",
|
||||
"postnl suivi colis",
|
||||
"suivi DHL colis",
|
||||
"suivi UPS",
|
||||
"suivi FedEx",
|
||||
"suivi international colis",
|
||||
"suivre ma livraison"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Trouvez des offres sur Bing",
|
||||
"queries": [
|
||||
"meilleures offres ordinateur portable",
|
||||
"bons plans tech",
|
||||
"promotions écouteurs",
|
||||
"offres chaise gamer",
|
||||
"codes promo électronique",
|
||||
"meilleures offres amazon aujourd'hui"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Explorez un nouvel endroit aujourd'hui",
|
||||
"queries": [
|
||||
"endroits à visiter près de chez moi",
|
||||
"que faire près de chez moi",
|
||||
"endroits insolites pays-bas",
|
||||
"meilleurs musées près de chez moi",
|
||||
"parcs près de chez moi",
|
||||
"attractions touristiques à proximité",
|
||||
"meilleurs cafés près de chez moi"
|
||||
]
|
||||
}
|
||||
]
|
||||
582
src/functions/queries.json
Normal file
582
src/functions/queries.json
Normal file
@@ -0,0 +1,582 @@
|
||||
[
|
||||
{
|
||||
"title": "Houses near you",
|
||||
"queries": [
|
||||
"Houses near me",
|
||||
"Homes for sale near me",
|
||||
"Apartments near me",
|
||||
"Real estate listings near me",
|
||||
"Zillow homes near me",
|
||||
"houses for rent near me"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Feeling symptoms?",
|
||||
"queries": [
|
||||
"Rash on forearm",
|
||||
"Stuffy nose",
|
||||
"Tickling cough",
|
||||
"sore throat remedies",
|
||||
"headache and nausea causes",
|
||||
"fever symptoms adults"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Get your shopping done faster",
|
||||
"queries": [
|
||||
"Buy PS5",
|
||||
"Buy Xbox",
|
||||
"Chair deals",
|
||||
"wireless mouse deals",
|
||||
"best gaming headset price",
|
||||
"laptop deals",
|
||||
"buy office chair",
|
||||
"SSD deals"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Translate anything",
|
||||
"queries": [
|
||||
"Translate welcome home to Korean",
|
||||
"Translate welcome home to Japanese",
|
||||
"Translate goodbye to Japanese",
|
||||
"Translate good morning to Spanish",
|
||||
"Translate thank you to French",
|
||||
"Translate see you later to Italian"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Search the lyrics of a song",
|
||||
"queries": [
|
||||
"Debarge rhythm of the night lyrics",
|
||||
"bohemian rhapsody lyrics",
|
||||
"hotel california lyrics",
|
||||
"blinding lights lyrics",
|
||||
"lose yourself lyrics",
|
||||
"smells like teen spirit lyrics"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Let's watch that movie again!",
|
||||
"queries": [
|
||||
"Alien movie",
|
||||
"Aliens movie",
|
||||
"Alien 3 movie",
|
||||
"Predator movie",
|
||||
"Terminator movie",
|
||||
"John Wick movie",
|
||||
"Interstellar movie",
|
||||
"The Matrix movie"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Plan a quick getaway",
|
||||
"queries": [
|
||||
"Flights Amsterdam to Tokyo",
|
||||
"Flights New York to Tokyo",
|
||||
"cheap flights to paris",
|
||||
"flights amsterdam to rome",
|
||||
"last minute flight deals",
|
||||
"direct flights from amsterdam",
|
||||
"weekend getaway europe",
|
||||
"best time to visit tokyo"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Discover open job roles",
|
||||
"queries": [
|
||||
"jobs at Microsoft",
|
||||
"Microsoft Job Openings",
|
||||
"Jobs near me",
|
||||
"jobs at Boeing worked",
|
||||
"software engineer jobs near me",
|
||||
"remote developer jobs",
|
||||
"IT jobs netherlands",
|
||||
"customer support jobs near me"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "You can track your package",
|
||||
"queries": [
|
||||
"USPS tracking",
|
||||
"UPS tracking",
|
||||
"DHL tracking",
|
||||
"FedEx tracking",
|
||||
"track my package",
|
||||
"international package tracking"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Find somewhere new to explore",
|
||||
"queries": [
|
||||
"Directions to Berlin",
|
||||
"Directions to Tokyo",
|
||||
"Directions to New York",
|
||||
"things to do in berlin",
|
||||
"tourist attractions tokyo",
|
||||
"best places to visit in new york",
|
||||
"hidden gems near me",
|
||||
"day trips near me"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Too tired to cook tonight?",
|
||||
"queries": [
|
||||
"KFC near me",
|
||||
"Burger King near me",
|
||||
"McDonalds near me",
|
||||
"pizza delivery near me",
|
||||
"restaurants open now",
|
||||
"best takeout near me",
|
||||
"quick dinner ideas",
|
||||
"easy dinner recipes"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Quickly convert your money",
|
||||
"queries": [
|
||||
"convert 250 USD to yen",
|
||||
"convert 500 USD to yen",
|
||||
"usd to eur",
|
||||
"gbp to eur",
|
||||
"eur to jpy",
|
||||
"currency converter",
|
||||
"exchange rate today",
|
||||
"1000 yen to euro"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Learn to cook a new recipe",
|
||||
"queries": [
|
||||
"How to cook ratatouille",
|
||||
"How to cook lasagna",
|
||||
"easy pasta recipe",
|
||||
"how to make pancakes",
|
||||
"how to make fried rice",
|
||||
"simple chicken recipe"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Find places to stay!",
|
||||
"queries": [
|
||||
"Hotels Berlin Germany",
|
||||
"Hotels Amsterdam Netherlands",
|
||||
"hotels in paris",
|
||||
"best hotels in tokyo",
|
||||
"cheap hotels london",
|
||||
"places to stay in barcelona",
|
||||
"hotel deals",
|
||||
"booking hotels near me"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "How's the economy?",
|
||||
"queries": [
|
||||
"sp 500",
|
||||
"nasdaq",
|
||||
"dow jones today",
|
||||
"inflation rate europe",
|
||||
"interest rates today",
|
||||
"stock market today",
|
||||
"economic news",
|
||||
"recession forecast"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Who won?",
|
||||
"queries": [
|
||||
"braves score",
|
||||
"champions league results",
|
||||
"premier league results",
|
||||
"nba score",
|
||||
"formula 1 winner",
|
||||
"latest football scores",
|
||||
"ucl final winner",
|
||||
"world cup final result"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Gaming time",
|
||||
"queries": [
|
||||
"Overwatch video game",
|
||||
"Call of duty video game",
|
||||
"best games 2025",
|
||||
"top xbox games",
|
||||
"popular steam games",
|
||||
"new pc games",
|
||||
"game reviews",
|
||||
"best co-op games"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Expand your vocabulary",
|
||||
"queries": [
|
||||
"definition definition",
|
||||
"meaning of serendipity",
|
||||
"define nostalgia",
|
||||
"synonym for happy",
|
||||
"define eloquent",
|
||||
"what does epiphany mean",
|
||||
"word of the day",
|
||||
"define immaculate"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "What time is it?",
|
||||
"queries": [
|
||||
"Japan time",
|
||||
"New York time",
|
||||
"time in london",
|
||||
"time in tokyo",
|
||||
"current time in amsterdam",
|
||||
"time in los angeles"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Find deals on Bing",
|
||||
"queries": [
|
||||
"best laptop deals",
|
||||
"tech deals today",
|
||||
"wireless earbuds deals",
|
||||
"gaming chair deals",
|
||||
"discount codes electronics",
|
||||
"best amazon deals today",
|
||||
"smartphone deals",
|
||||
"ssd deals"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Prepare for the weather",
|
||||
"queries": [
|
||||
"weather tomorrow",
|
||||
"weekly weather forecast",
|
||||
"rain forecast today",
|
||||
"weather in amsterdam",
|
||||
"storm forecast europe",
|
||||
"uv index today",
|
||||
"temperature this weekend",
|
||||
"snow forecast"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Track your delivery",
|
||||
"queries": [
|
||||
"track my package",
|
||||
"postnl track and trace",
|
||||
"dhl parcel tracking",
|
||||
"ups tracking",
|
||||
"fedex tracking",
|
||||
"usps tracking",
|
||||
"parcel tracking",
|
||||
"international package tracking"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Explore a new spot today",
|
||||
"queries": [
|
||||
"places to visit near me",
|
||||
"things to do near me",
|
||||
"hidden gems netherlands",
|
||||
"best museums near me",
|
||||
"parks near me",
|
||||
"tourist attractions nearby",
|
||||
"best cafes near me",
|
||||
"day trip ideas"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Maisons près de chez vous",
|
||||
"queries": [
|
||||
"Maisons près de chez moi",
|
||||
"Maisons à vendre près de chez moi",
|
||||
"Appartements près de chez moi",
|
||||
"Annonces immobilières près de chez moi",
|
||||
"Maisons à louer près de chez moi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Vous ressentez des symptômes ?",
|
||||
"queries": [
|
||||
"Éruption cutanée sur l'avant-bras",
|
||||
"Nez bouché",
|
||||
"Toux chatouilleuse",
|
||||
"mal de gorge remèdes",
|
||||
"maux de tête causes",
|
||||
"symptômes de la grippe"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Faites vos achats plus vite",
|
||||
"queries": [
|
||||
"Acheter une PS5",
|
||||
"Acheter une Xbox",
|
||||
"Offres sur les chaises",
|
||||
"offres ordinateur portable",
|
||||
"meilleures offres casque",
|
||||
"acheter souris sans fil",
|
||||
"promotions ssd",
|
||||
"bons plans tech"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Traduisez tout !",
|
||||
"queries": [
|
||||
"Traduction bienvenue à la maison en coréen",
|
||||
"Traduction bienvenue à la maison en japonais",
|
||||
"Traduction au revoir en japonais",
|
||||
"Traduire bonjour en espagnol",
|
||||
"Traduire merci en anglais",
|
||||
"Traduire à plus tard en italien"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Rechercher paroles de chanson",
|
||||
"queries": [
|
||||
"Paroles de Debarge rhythm of the night",
|
||||
"paroles bohemian rhapsody",
|
||||
"paroles hotel california",
|
||||
"paroles blinding lights",
|
||||
"paroles lose yourself",
|
||||
"paroles smells like teen spirit"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Et si nous regardions ce film une nouvelle fois?",
|
||||
"queries": [
|
||||
"Alien film",
|
||||
"Film Aliens",
|
||||
"Film Alien 3",
|
||||
"Film Predator",
|
||||
"Film Terminator",
|
||||
"Film John Wick",
|
||||
"Film Interstellar",
|
||||
"Film Matrix"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Planifiez une petite escapade",
|
||||
"queries": [
|
||||
"Vols Amsterdam-Tokyo",
|
||||
"Vols New York-Tokyo",
|
||||
"vols pas chers paris",
|
||||
"vols amsterdam rome",
|
||||
"offres vols dernière minute",
|
||||
"week-end en europe",
|
||||
"vols directs depuis amsterdam"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Consulter postes à pourvoir",
|
||||
"queries": [
|
||||
"emplois chez Microsoft",
|
||||
"Offres d'emploi Microsoft",
|
||||
"Emplois près de chez moi",
|
||||
"emplois chez Boeing",
|
||||
"emplois développeur à distance",
|
||||
"emplois informatique pays-bas",
|
||||
"offres d'emploi près de chez moi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Vous pouvez suivre votre colis",
|
||||
"queries": [
|
||||
"Suivi Chronopost",
|
||||
"suivi colis",
|
||||
"suivi DHL",
|
||||
"suivi UPS",
|
||||
"suivi FedEx",
|
||||
"suivi international colis"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Trouver un endroit à découvrir",
|
||||
"queries": [
|
||||
"Itinéraire vers Berlin",
|
||||
"Itinéraire vers Tokyo",
|
||||
"Itinéraire vers New York",
|
||||
"que faire à berlin",
|
||||
"attractions tokyo",
|
||||
"meilleurs endroits à visiter à new york",
|
||||
"endroits à visiter près de chez moi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"livraison pizza près de chez moi",
|
||||
"restaurants ouverts maintenant",
|
||||
"idées dîner rapide",
|
||||
"quoi manger ce soir"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Convertissez rapidement votre argent",
|
||||
"queries": [
|
||||
"convertir 250 EUR en yen",
|
||||
"convertir 500 EUR en yen",
|
||||
"usd en eur",
|
||||
"gbp en eur",
|
||||
"eur en jpy",
|
||||
"convertisseur de devises",
|
||||
"taux de change aujourd'hui",
|
||||
"1000 yen en euro"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Apprenez à cuisiner une nouvelle recette",
|
||||
"queries": [
|
||||
"Comment faire cuire la ratatouille",
|
||||
"Comment faire cuire les lasagnes",
|
||||
"recette pâtes facile",
|
||||
"comment faire des crêpes",
|
||||
"recette riz sauté",
|
||||
"recette poulet simple"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Trouvez des emplacements pour rester!",
|
||||
"queries": [
|
||||
"Hôtels Berlin Allemagne",
|
||||
"Hôtels Amsterdam Pays-Bas",
|
||||
"hôtels paris",
|
||||
"meilleurs hôtels tokyo",
|
||||
"hôtels pas chers londres",
|
||||
"hébergement barcelone",
|
||||
"offres hôtels",
|
||||
"hôtels près de chez moi"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Comment se porte l'économie ?",
|
||||
"queries": [
|
||||
"CAC 40",
|
||||
"indice dax",
|
||||
"dow jones aujourd'hui",
|
||||
"inflation europe",
|
||||
"taux d'intérêt aujourd'hui",
|
||||
"marché boursier aujourd'hui",
|
||||
"actualités économie",
|
||||
"prévisions récession"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Qui a gagné ?",
|
||||
"queries": [
|
||||
"score du Paris Saint-Germain",
|
||||
"résultats ligue des champions",
|
||||
"résultats premier league",
|
||||
"score nba",
|
||||
"vainqueur formule 1",
|
||||
"derniers scores football",
|
||||
"vainqueur finale ldc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Temps de jeu",
|
||||
"queries": [
|
||||
"Jeu vidéo Overwatch",
|
||||
"Jeu vidéo Call of Duty",
|
||||
"meilleurs jeux 2025",
|
||||
"top jeux xbox",
|
||||
"jeux steam populaires",
|
||||
"nouveaux jeux pc",
|
||||
"avis jeux vidéo",
|
||||
"meilleurs jeux coop"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Enrichissez votre vocabulaire",
|
||||
"queries": [
|
||||
"definition definition",
|
||||
"signification sérendipité",
|
||||
"définir nostalgie",
|
||||
"synonyme heureux",
|
||||
"définir éloquent",
|
||||
"mot du jour",
|
||||
"que veut dire épiphanie"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Quelle heure est-il ?",
|
||||
"queries": [
|
||||
"Heure du Japon",
|
||||
"Heure de New York",
|
||||
"heure de londres",
|
||||
"heure de tokyo",
|
||||
"heure actuelle amsterdam",
|
||||
"heure de los angeles"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Vérifier la météo",
|
||||
"queries": [
|
||||
"Météo de Paris",
|
||||
"Météo de la France",
|
||||
"météo demain",
|
||||
"prévisions météo semaine",
|
||||
"météo amsterdam",
|
||||
"risque de pluie aujourd'hui"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Tenez-vous informé des sujets d'actualité",
|
||||
"queries": [
|
||||
"Augmentation Impots",
|
||||
"Mort célébrité",
|
||||
"actualités france",
|
||||
"actualité internationale",
|
||||
"dernières nouvelles économie",
|
||||
"news technologie"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Préparez-vous pour la météo",
|
||||
"queries": [
|
||||
"météo demain",
|
||||
"prévisions météo semaine",
|
||||
"météo amsterdam",
|
||||
"risque de pluie aujourd'hui",
|
||||
"indice uv aujourd'hui",
|
||||
"température ce week-end",
|
||||
"alerte tempête"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Suivez votre livraison",
|
||||
"queries": [
|
||||
"suivi colis",
|
||||
"postnl suivi colis",
|
||||
"suivi DHL colis",
|
||||
"suivi UPS",
|
||||
"suivi FedEx",
|
||||
"suivi international colis",
|
||||
"suivre ma livraison"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Trouvez des offres sur Bing",
|
||||
"queries": [
|
||||
"meilleures offres ordinateur portable",
|
||||
"bons plans tech",
|
||||
"promotions écouteurs",
|
||||
"offres chaise gamer",
|
||||
"codes promo électronique",
|
||||
"meilleures offres amazon aujourd'hui"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Explorez un nouvel endroit aujourd'hui",
|
||||
"queries": [
|
||||
"endroits à visiter près de chez moi",
|
||||
"que faire près de chez moi",
|
||||
"endroits insolites pays-bas",
|
||||
"meilleurs musées près de chez moi",
|
||||
"parcs près de chez moi",
|
||||
"attractions touristiques à proximité",
|
||||
"meilleurs cafés près de chez moi"
|
||||
]
|
||||
}
|
||||
]
|
||||
116
src/functions/search-queries.json
Normal file
116
src/functions/search-queries.json
Normal file
@@ -0,0 +1,116 @@
|
||||
[
|
||||
"weather tomorrow",
|
||||
"how to cook pasta",
|
||||
"best movies 2024",
|
||||
"latest tech news",
|
||||
"how tall is the eiffel tower",
|
||||
"easy dinner recipes",
|
||||
"what time is it in japan",
|
||||
"how does photosynthesis work",
|
||||
"best budget smartphones",
|
||||
"coffee vs espresso difference",
|
||||
"how to improve wifi signal",
|
||||
"popular netflix series",
|
||||
"how many calories in an apple",
|
||||
"world population today",
|
||||
"best free pc games",
|
||||
"how to clean a keyboard",
|
||||
"what is artificial intelligence",
|
||||
"simple home workouts",
|
||||
"how long do cats live",
|
||||
"famous paintings in museums",
|
||||
"how to boil eggs",
|
||||
"latest windows updates",
|
||||
"how to screenshot on windows",
|
||||
"best travel destinations europe",
|
||||
"what is cloud computing",
|
||||
"how to save money monthly",
|
||||
"best youtube channels",
|
||||
"how fast is light",
|
||||
"how to learn programming",
|
||||
"popular board games",
|
||||
"how to make pancakes",
|
||||
"capital cities of europe",
|
||||
"how does a vpn work",
|
||||
"best productivity apps",
|
||||
"how to grow plants indoors",
|
||||
"difference between hdd and ssd",
|
||||
"how to fix slow computer",
|
||||
"most streamed songs",
|
||||
"how to tie a tie",
|
||||
"what causes rain",
|
||||
"best laptops for students",
|
||||
"how to reset router",
|
||||
"healthy breakfast ideas",
|
||||
"how many continents are there",
|
||||
"latest smartphone features",
|
||||
"how to meditate beginners",
|
||||
"what is renewable energy",
|
||||
"best pc accessories",
|
||||
"how to clean glasses",
|
||||
"famous landmarks worldwide",
|
||||
"how to make coffee at home",
|
||||
"what is machine learning",
|
||||
"best programming languages",
|
||||
"how to backup files",
|
||||
"how does bluetooth work",
|
||||
"top video games right now",
|
||||
"how to improve sleep quality",
|
||||
"what is cryptocurrency",
|
||||
"easy lunch ideas",
|
||||
"how to check internet speed",
|
||||
"best noise cancelling headphones",
|
||||
"how to take screenshots on mac",
|
||||
"what is the milky way",
|
||||
"how to organize files",
|
||||
"popular mobile apps",
|
||||
"how to learn faster",
|
||||
"how does gps work",
|
||||
"best free antivirus",
|
||||
"how to clean a monitor",
|
||||
"what is an electric car",
|
||||
"simple math tricks",
|
||||
"how to update drivers",
|
||||
"famous scientists",
|
||||
"how to cook rice",
|
||||
"what is the tallest mountain",
|
||||
"best tv shows all time",
|
||||
"how to improve typing speed",
|
||||
"how does solar power work",
|
||||
"easy dessert recipes",
|
||||
"how to fix bluetooth issues",
|
||||
"what is the internet",
|
||||
"best pc keyboards",
|
||||
"how to stay focused",
|
||||
"popular science facts",
|
||||
"how to convert files to pdf",
|
||||
"how long does it take to sleep",
|
||||
"best travel tips",
|
||||
"how to clean headphones",
|
||||
"what is open source software",
|
||||
"how to manage time better",
|
||||
"latest gaming news",
|
||||
"how to check laptop temperature",
|
||||
"what is a firewall",
|
||||
"easy meal prep ideas",
|
||||
"how to reduce eye strain",
|
||||
"best budget headphones",
|
||||
"how does email work",
|
||||
"what is virtual reality",
|
||||
"how to compress files",
|
||||
"popular programming tools",
|
||||
"how to improve concentration",
|
||||
"how to make smoothies",
|
||||
"best desk setup ideas",
|
||||
"how to block ads",
|
||||
"what is 5g technology",
|
||||
"how to clean a mouse",
|
||||
"famous world wonders",
|
||||
"how to improve battery life",
|
||||
"best cloud storage services",
|
||||
"how to learn a new language",
|
||||
"what is dark mode",
|
||||
"how to clear browser cache",
|
||||
"popular tech podcasts",
|
||||
"how to stay motivated"
|
||||
]
|
||||
618
src/index.ts
618
src/index.ts
@@ -1,240 +1,512 @@
|
||||
import cluster from 'cluster'
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import cluster, { Worker } from 'cluster'
|
||||
import type { BrowserContext, Cookie, Page } from 'patchright'
|
||||
import pkg from '../package.json'
|
||||
|
||||
import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
||||
|
||||
import Browser from './browser/Browser'
|
||||
import BrowserFunc from './browser/BrowserFunc'
|
||||
import BrowserUtil from './browser/BrowserUtil'
|
||||
import BrowserUtils from './browser/BrowserUtils'
|
||||
|
||||
import { log } from './util/Logger'
|
||||
import Util from './util/Utils'
|
||||
import { IpcLog, Logger } from './logging/Logger'
|
||||
import Utils from './util/Utils'
|
||||
import { loadAccounts, loadConfig } from './util/Load'
|
||||
import { checkNodeVersion } from './util/Validator'
|
||||
|
||||
import { Login } from './functions/Login'
|
||||
import { Login } from './browser/auth/Login'
|
||||
import { Workers } from './functions/Workers'
|
||||
import Activities from './functions/Activities'
|
||||
import { SearchManager } from './functions/SearchManager'
|
||||
|
||||
import { Account } from './interface/Account'
|
||||
import type { Account } from './interface/Account'
|
||||
import AxiosClient from './util/Axios'
|
||||
import { sendDiscord, flushDiscordQueue } from './logging/Discord'
|
||||
import { sendNtfy, flushNtfyQueue } from './logging/Ntfy'
|
||||
import type { DashboardData } from './interface/DashboardData'
|
||||
import type { AppDashboardData } from './interface/AppDashBoardData'
|
||||
|
||||
// Main bot class
|
||||
export class MicrosoftRewardsBot {
|
||||
public log: typeof log
|
||||
public config
|
||||
public utils: Util
|
||||
public activities: Activities = new Activities(this)
|
||||
public browser: {
|
||||
func: BrowserFunc,
|
||||
utils: BrowserUtil
|
||||
interface ExecutionContext {
|
||||
isMobile: boolean
|
||||
account: Account
|
||||
}
|
||||
|
||||
interface BrowserSession {
|
||||
context: BrowserContext
|
||||
fingerprint: BrowserFingerprintWithHeaders
|
||||
}
|
||||
|
||||
interface AccountStats {
|
||||
email: string
|
||||
initialPoints: number
|
||||
finalPoints: number
|
||||
collectedPoints: number
|
||||
duration: number
|
||||
success: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
const executionContext = new AsyncLocalStorage<ExecutionContext>()
|
||||
|
||||
export function getCurrentContext(): ExecutionContext {
|
||||
const context = executionContext.getStore()
|
||||
if (!context) {
|
||||
return { isMobile: false, account: {} as any }
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
async function flushAllWebhooks(timeoutMs = 5000): Promise<void> {
|
||||
await Promise.allSettled([flushDiscordQueue(timeoutMs), flushNtfyQueue(timeoutMs)])
|
||||
}
|
||||
|
||||
interface UserData {
|
||||
userName: string
|
||||
geoLocale: string
|
||||
langCode: string
|
||||
initialPoints: number
|
||||
currentPoints: number
|
||||
gainedPoints: number
|
||||
}
|
||||
|
||||
export class MicrosoftRewardsBot {
|
||||
public logger: Logger
|
||||
public config
|
||||
public utils: Utils
|
||||
public activities: Activities = new Activities(this)
|
||||
public browser: { func: BrowserFunc; utils: BrowserUtils }
|
||||
|
||||
public mainMobilePage!: Page
|
||||
public mainDesktopPage!: Page
|
||||
|
||||
public userData: UserData
|
||||
|
||||
public accessToken = ''
|
||||
public requestToken = ''
|
||||
public cookies: { mobile: Cookie[]; desktop: Cookie[] }
|
||||
public fingerprint!: BrowserFingerprintWithHeaders
|
||||
|
||||
private pointsCanCollect = 0
|
||||
|
||||
private collectedPoints: number = 0
|
||||
private activeWorkers: number
|
||||
private exitedWorkers: number[]
|
||||
private browserFactory: Browser = new Browser(this)
|
||||
private accounts: Account[]
|
||||
private workers: Workers
|
||||
private login = new Login(this)
|
||||
private searchManager: SearchManager
|
||||
|
||||
public axios!: AxiosClient
|
||||
|
||||
constructor() {
|
||||
this.log = log
|
||||
|
||||
this.userData = {
|
||||
userName: '',
|
||||
geoLocale: 'US',
|
||||
langCode: 'en',
|
||||
initialPoints: 0,
|
||||
currentPoints: 0,
|
||||
gainedPoints: 0
|
||||
}
|
||||
this.logger = new Logger(this)
|
||||
this.accounts = []
|
||||
this.utils = new Util()
|
||||
this.cookies = { mobile: [], desktop: [] }
|
||||
this.utils = new Utils()
|
||||
this.workers = new Workers(this)
|
||||
this.searchManager = new SearchManager(this)
|
||||
this.browser = {
|
||||
func: new BrowserFunc(this),
|
||||
utils: new BrowserUtil(this)
|
||||
utils: new BrowserUtils(this)
|
||||
}
|
||||
this.config = loadConfig()
|
||||
this.activeWorkers = this.config.clusters
|
||||
this.exitedWorkers = []
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
get isMobile(): boolean {
|
||||
return getCurrentContext().isMobile
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
this.accounts = loadAccounts()
|
||||
}
|
||||
|
||||
async run() {
|
||||
log('MAIN', `Bot started with ${this.config.clusters} clusters`)
|
||||
async run(): Promise<void> {
|
||||
const totalAccounts = this.accounts.length
|
||||
const runStartTime = Date.now()
|
||||
|
||||
this.logger.info(
|
||||
'main',
|
||||
'RUN-START',
|
||||
`Starting Microsoft Rewards Script | v${pkg.version} | Accounts: ${totalAccounts} | Clusters: ${this.config.clusters}`
|
||||
)
|
||||
|
||||
// Only cluster when there's more than 1 cluster demanded
|
||||
if (this.config.clusters > 1) {
|
||||
if (cluster.isPrimary) {
|
||||
this.runMaster()
|
||||
this.runMaster(runStartTime)
|
||||
} else {
|
||||
this.runWorker()
|
||||
this.runWorker(runStartTime)
|
||||
}
|
||||
} else {
|
||||
this.runTasks(this.accounts)
|
||||
await this.runTasks(this.accounts, runStartTime)
|
||||
}
|
||||
}
|
||||
|
||||
private runMaster() {
|
||||
log('MAIN-PRIMARY', 'Primary process started')
|
||||
private runMaster(runStartTime: number): void {
|
||||
void this.logger.info('main', 'CLUSTER-PRIMARY', `Primary process started | PID: ${process.pid}`)
|
||||
|
||||
const accountChunks = this.utils.chunkArray(this.accounts, this.config.clusters)
|
||||
const rawChunks = this.utils.chunkArray(this.accounts, this.config.clusters)
|
||||
const accountChunks = rawChunks.filter(c => c && c.length > 0)
|
||||
this.activeWorkers = accountChunks.length
|
||||
|
||||
for (let i = 0; i < accountChunks.length; i++) {
|
||||
const allAccountStats: AccountStats[] = []
|
||||
|
||||
for (const chunk of accountChunks) {
|
||||
const worker = cluster.fork()
|
||||
const chunk = accountChunks[i]
|
||||
worker.send({ chunk })
|
||||
worker.send?.({ chunk, runStartTime })
|
||||
|
||||
worker.on('message', (msg: { __ipcLog?: IpcLog; __stats?: AccountStats[] }) => {
|
||||
if (msg.__stats) {
|
||||
allAccountStats.push(...msg.__stats)
|
||||
}
|
||||
|
||||
const log = msg.__ipcLog
|
||||
|
||||
if (log && typeof log.content === 'string') {
|
||||
const config = this.config
|
||||
const webhook = config.webhook
|
||||
const content = log.content
|
||||
const level = log.level
|
||||
if (webhook.discord?.enabled && webhook.discord.url) {
|
||||
sendDiscord(webhook.discord.url, content, level)
|
||||
}
|
||||
if (webhook.ntfy?.enabled && webhook.ntfy.url) {
|
||||
sendNtfy(webhook.ntfy, content, level)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onWorkerDone = async (label: 'exit' | 'disconnect', worker: Worker, code?: number): Promise<void> => {
|
||||
const { pid } = worker.process
|
||||
this.activeWorkers -= 1
|
||||
|
||||
if (!pid || this.exitedWorkers.includes(pid)) {
|
||||
return
|
||||
} else {
|
||||
this.exitedWorkers.push(pid)
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
'main',
|
||||
`CLUSTER-WORKER-${label.toUpperCase()}`,
|
||||
`Worker ${worker.process?.pid ?? '?'} ${label} | Code: ${code ?? 'n/a'} | Active workers: ${this.activeWorkers}`
|
||||
)
|
||||
if (this.activeWorkers <= 0) {
|
||||
const totalCollectedPoints = allAccountStats.reduce((sum, s) => sum + s.collectedPoints, 0)
|
||||
const totalInitialPoints = allAccountStats.reduce((sum, s) => sum + s.initialPoints, 0)
|
||||
const totalFinalPoints = allAccountStats.reduce((sum, s) => sum + s.finalPoints, 0)
|
||||
const totalDurationMinutes = ((Date.now() - runStartTime) / 1000 / 60).toFixed(1)
|
||||
|
||||
this.logger.info(
|
||||
'main',
|
||||
'RUN-END',
|
||||
`Completed all accounts | Accounts processed: ${allAccountStats.length} | Total points collected: +${totalCollectedPoints} | Old total: ${totalInitialPoints} → New total: ${totalFinalPoints} | Total runtime: ${totalDurationMinutes}min`,
|
||||
'green'
|
||||
)
|
||||
await flushAllWebhooks()
|
||||
process.exit(code ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
cluster.on('exit', (worker, code) => {
|
||||
this.activeWorkers -= 1
|
||||
void onWorkerDone('exit', worker, code)
|
||||
})
|
||||
cluster.on('disconnect', worker => {
|
||||
void onWorkerDone('disconnect', worker, undefined)
|
||||
})
|
||||
}
|
||||
|
||||
log('MAIN-WORKER', `Worker ${worker.process.pid} destroyed | Code: ${code} | Active workers: ${this.activeWorkers}`, 'warn')
|
||||
private runWorker(runStartTimeFromMaster?: number): void {
|
||||
void this.logger.info('main', 'CLUSTER-WORKER-START', `Worker spawned | PID: ${process.pid}`)
|
||||
process.on('message', async ({ chunk, runStartTime }: { chunk: Account[]; runStartTime: number }) => {
|
||||
void this.logger.info(
|
||||
'main',
|
||||
'CLUSTER-WORKER-TASK',
|
||||
`Worker ${process.pid} received ${chunk.length} accounts.`
|
||||
)
|
||||
try {
|
||||
const stats = await this.runTasks(chunk, runStartTime ?? runStartTimeFromMaster ?? Date.now())
|
||||
if (process.send) {
|
||||
process.send({ __stats: stats })
|
||||
}
|
||||
|
||||
// Check if all workers have exited
|
||||
if (this.activeWorkers === 0) {
|
||||
log('MAIN-WORKER', 'All workers destroyed. Exiting main process!', 'warn')
|
||||
process.exit(0)
|
||||
process.disconnect()
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'main',
|
||||
'CLUSTER-WORKER-ERROR',
|
||||
`Worker task crash: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
await flushAllWebhooks()
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private runWorker() {
|
||||
log('MAIN-WORKER', `Worker ${process.pid} spawned`)
|
||||
// Receive the chunk of accounts from the master
|
||||
process.on('message', async ({ chunk }) => {
|
||||
await this.runTasks(chunk)
|
||||
})
|
||||
}
|
||||
private async runTasks(accounts: Account[], runStartTime: number): Promise<AccountStats[]> {
|
||||
const accountStats: AccountStats[] = []
|
||||
|
||||
private async runTasks(accounts: Account[]) {
|
||||
for (const account of accounts) {
|
||||
log('MAIN-WORKER', `Started tasks for account ${account.email}`)
|
||||
const accountStartTime = Date.now()
|
||||
const accountEmail = account.email
|
||||
this.userData.userName = this.utils.getEmailUsername(accountEmail)
|
||||
|
||||
// Desktop Searches, DailySet and More Promotions
|
||||
await this.Desktop(account)
|
||||
try {
|
||||
this.logger.info(
|
||||
'main',
|
||||
'ACCOUNT-START',
|
||||
`Starting account: ${accountEmail} | geoLocale: ${account.geoLocale}`
|
||||
)
|
||||
|
||||
// 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 AxiosClient(account.proxy)
|
||||
|
||||
const result: { initialPoints: number; collectedPoints: number } | undefined = await this.Main(
|
||||
account
|
||||
).catch(error => {
|
||||
void this.logger.error(
|
||||
true,
|
||||
'FLOW',
|
||||
`Mobile flow failed for ${accountEmail}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
return undefined
|
||||
})
|
||||
|
||||
const durationSeconds = ((Date.now() - accountStartTime) / 1000).toFixed(1)
|
||||
|
||||
if (result) {
|
||||
const collectedPoints = result.collectedPoints ?? 0
|
||||
const accountInitialPoints = result.initialPoints ?? 0
|
||||
const accountFinalPoints = accountInitialPoints + collectedPoints
|
||||
|
||||
accountStats.push({
|
||||
email: accountEmail,
|
||||
initialPoints: accountInitialPoints,
|
||||
finalPoints: accountFinalPoints,
|
||||
collectedPoints: collectedPoints,
|
||||
duration: parseFloat(durationSeconds),
|
||||
success: true
|
||||
})
|
||||
|
||||
this.logger.info(
|
||||
'main',
|
||||
'ACCOUNT-END',
|
||||
`Completed account: ${accountEmail} | Total: +${collectedPoints} | Old: ${accountInitialPoints} → New: ${accountFinalPoints} | Duration: ${durationSeconds}s`,
|
||||
'green'
|
||||
)
|
||||
} else {
|
||||
accountStats.push({
|
||||
email: accountEmail,
|
||||
initialPoints: 0,
|
||||
finalPoints: 0,
|
||||
collectedPoints: 0,
|
||||
duration: parseFloat(durationSeconds),
|
||||
success: false,
|
||||
error: 'Flow failed'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
const durationSeconds = ((Date.now() - accountStartTime) / 1000).toFixed(1)
|
||||
this.logger.error(
|
||||
'main',
|
||||
'ACCOUNT-ERROR',
|
||||
`${accountEmail}: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
|
||||
accountStats.push({
|
||||
email: accountEmail,
|
||||
initialPoints: 0,
|
||||
finalPoints: 0,
|
||||
collectedPoints: 0,
|
||||
duration: parseFloat(durationSeconds),
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
})
|
||||
}
|
||||
|
||||
// Mobile Searches
|
||||
await this.Mobile(account)
|
||||
|
||||
log('MAIN-WORKER', `Completed tasks for account ${account.email}`)
|
||||
}
|
||||
|
||||
log('MAIN-PRIMARY', 'Completed tasks for ALL accounts')
|
||||
log('MAIN-PRIMARY', 'All workers destroyed!')
|
||||
process.exit(0)
|
||||
if (this.config.clusters <= 1 && !cluster.isWorker) {
|
||||
const totalCollectedPoints = accountStats.reduce((sum, s) => sum + s.collectedPoints, 0)
|
||||
const totalInitialPoints = accountStats.reduce((sum, s) => sum + s.initialPoints, 0)
|
||||
const totalFinalPoints = accountStats.reduce((sum, s) => sum + s.finalPoints, 0)
|
||||
const totalDurationMinutes = ((Date.now() - runStartTime) / 1000 / 60).toFixed(1)
|
||||
|
||||
this.logger.info(
|
||||
'main',
|
||||
'RUN-END',
|
||||
`Completed all accounts | Accounts processed: ${accountStats.length} | Total points collected: +${totalCollectedPoints} | Old total: ${totalInitialPoints} → New total: ${totalFinalPoints} | Total runtime: ${totalDurationMinutes}min`,
|
||||
'green'
|
||||
)
|
||||
|
||||
await flushAllWebhooks()
|
||||
process.exit()
|
||||
}
|
||||
|
||||
return accountStats
|
||||
}
|
||||
|
||||
// Desktop
|
||||
async Desktop(account: Account) {
|
||||
const browser = await this.browserFactory.createBrowser(account.email, account.proxy, false)
|
||||
const page = await browser.newPage()
|
||||
let pages = await browser.pages()
|
||||
async Main(account: Account): Promise<{ initialPoints: number; collectedPoints: number }> {
|
||||
const accountEmail = account.email
|
||||
this.logger.info('main', 'FLOW', `Starting session for ${accountEmail}`)
|
||||
|
||||
// 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()
|
||||
let mobileSession: BrowserSession | null = null
|
||||
let mobileContextClosed = false
|
||||
|
||||
try {
|
||||
return await executionContext.run({ isMobile: true, account }, async () => {
|
||||
mobileSession = await this.browserFactory.createBrowser(account)
|
||||
const initialContext: BrowserContext = mobileSession.context
|
||||
this.mainMobilePage = await initialContext.newPage()
|
||||
|
||||
this.logger.info('main', 'BROWSER', `Mobile Browser started | ${accountEmail}`)
|
||||
|
||||
await this.login.login(this.mainMobilePage, account)
|
||||
|
||||
try {
|
||||
this.accessToken = await this.login.getAppAccessToken(this.mainMobilePage, accountEmail)
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
'main',
|
||||
'FLOW',
|
||||
`Failed to get mobile access token: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
|
||||
this.cookies.mobile = await initialContext.cookies()
|
||||
this.fingerprint = mobileSession.fingerprint
|
||||
|
||||
const data: DashboardData = await this.browser.func.getDashboardData()
|
||||
const appData: AppDashboardData = await this.browser.func.getAppDashboardData()
|
||||
|
||||
// Set geo
|
||||
this.userData.geoLocale =
|
||||
account.geoLocale === 'auto' ? data.userProfile.attributes.country : account.geoLocale.toLowerCase()
|
||||
if (this.userData.geoLocale.length > 2) {
|
||||
this.logger.warn(
|
||||
'main',
|
||||
'GEO-LOCALE',
|
||||
`The provided geoLocale is longer than 2 (${this.userData.geoLocale} | auto=${account.geoLocale === 'auto'}), this is likely invalid and can cause errors!`
|
||||
)
|
||||
}
|
||||
|
||||
this.userData.initialPoints = data.userStatus.availablePoints
|
||||
this.userData.currentPoints = data.userStatus.availablePoints
|
||||
const initialPoints = this.userData.initialPoints ?? 0
|
||||
|
||||
const browserEarnable = await this.browser.func.getBrowserEarnablePoints()
|
||||
const appEarnable = await this.browser.func.getAppEarnablePoints()
|
||||
|
||||
this.pointsCanCollect = browserEarnable.mobileSearchPoints + (appEarnable?.totalEarnablePoints ?? 0)
|
||||
|
||||
this.logger.info(
|
||||
'main',
|
||||
'POINTS',
|
||||
`Earnable today | Mobile: ${this.pointsCanCollect} | Browser: ${
|
||||
browserEarnable.mobileSearchPoints
|
||||
} | App: ${appEarnable?.totalEarnablePoints ?? 0} | ${accountEmail} | locale: ${this.userData.geoLocale}`
|
||||
)
|
||||
|
||||
if (this.config.workers.doAppPromotions) await this.workers.doAppPromotions(appData)
|
||||
if (this.config.workers.doDailySet) await this.workers.doDailySet(data, this.mainMobilePage)
|
||||
if (this.config.workers.doSpecialPromotions) await this.workers.doSpecialPromotions(data)
|
||||
if (this.config.workers.doMorePromotions) await this.workers.doMorePromotions(data, this.mainMobilePage)
|
||||
if (this.config.workers.doDailyCheckIn) await this.activities.doDailyCheckIn()
|
||||
if (this.config.workers.doReadToEarn) await this.activities.doReadToEarn()
|
||||
|
||||
const searchPoints = await this.browser.func.getSearchPoints()
|
||||
const missingSearchPoints = this.browser.func.missingSearchPoints(searchPoints, true)
|
||||
|
||||
this.cookies.mobile = await initialContext.cookies()
|
||||
|
||||
const { mobilePoints, desktopPoints } = await this.searchManager.doSearches(
|
||||
data,
|
||||
missingSearchPoints,
|
||||
mobileSession,
|
||||
account,
|
||||
accountEmail
|
||||
)
|
||||
|
||||
mobileContextClosed = true
|
||||
|
||||
this.userData.gainedPoints = mobilePoints + desktopPoints
|
||||
|
||||
const finalPoints = await this.browser.func.getCurrentPoints()
|
||||
const collectedPoints = finalPoints - initialPoints
|
||||
|
||||
this.logger.info(
|
||||
'main',
|
||||
'FLOW',
|
||||
`Collected: +${collectedPoints} | Mobile: +${mobilePoints} | Desktop: +${desktopPoints} | ${accountEmail}`
|
||||
)
|
||||
|
||||
return {
|
||||
initialPoints,
|
||||
collectedPoints: collectedPoints || 0
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
if (mobileSession && !mobileContextClosed) {
|
||||
try {
|
||||
await executionContext.run({ isMobile: true, account }, async () => {
|
||||
await this.browser.func.closeBrowser(mobileSession!.context, accountEmail)
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// Log into proxy
|
||||
await page.authenticate({ username: account.proxy.username, password: account.proxy.password })
|
||||
|
||||
log('MAIN', 'Starting DESKTOP browser')
|
||||
|
||||
// Login into MS Rewards
|
||||
await this.login.login(page, account.email, account.password)
|
||||
|
||||
const wentHome = await this.browser.func.goHome(page)
|
||||
if (!wentHome) {
|
||||
throw log('MAIN', 'Unable to get dashboard page', 'error')
|
||||
}
|
||||
|
||||
const data = await this.browser.func.getDashboardData(page)
|
||||
log('MAIN-POINTS', `Current point count: ${data.userStatus.availablePoints}`)
|
||||
|
||||
const earnablePoints = await this.browser.func.getEarnablePoints(data)
|
||||
this.collectedPoints = earnablePoints
|
||||
log('MAIN-POINTS', `You can earn ${earnablePoints} points today`)
|
||||
|
||||
// If runOnZeroPoints is false and 0 points to earn, don't continue
|
||||
if (!this.config.runOnZeroPoints && this.collectedPoints === 0) {
|
||||
log('MAIN', 'No points to earn and "runOnZeroPoints" is set to "false", stopping')
|
||||
|
||||
// Close desktop browser
|
||||
return await browser.close()
|
||||
}
|
||||
|
||||
// Complete daily set
|
||||
if (this.config.workers.doDailySet) {
|
||||
await this.workers.doDailySet(page, data)
|
||||
}
|
||||
|
||||
// Complete more promotions
|
||||
if (this.config.workers.doMorePromotions) {
|
||||
await this.workers.doMorePromotions(page, data)
|
||||
}
|
||||
|
||||
// Complete punch cards
|
||||
if (this.config.workers.doPunchCards) {
|
||||
await this.workers.doPunchCard(page, data)
|
||||
}
|
||||
|
||||
// Do desktop searches
|
||||
if (this.config.workers.doDesktopSearch) {
|
||||
await this.activities.doSearch(page, data, false)
|
||||
}
|
||||
|
||||
// Close desktop browser
|
||||
await browser.close()
|
||||
}
|
||||
|
||||
// Mobile
|
||||
async Mobile(account: Account) {
|
||||
const browser = await this.browserFactory.createBrowser(account.email, account.proxy, true)
|
||||
const page = await browser.newPage()
|
||||
let pages = await browser.pages()
|
||||
|
||||
// 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 into proxy
|
||||
await page.authenticate({ username: account.proxy.username, password: account.proxy.password })
|
||||
|
||||
log('MAIN', 'Starting MOBILE browser')
|
||||
|
||||
// Login into MS Rewards
|
||||
await this.login.login(page, account.email, account.password)
|
||||
|
||||
await this.browser.func.goHome(page)
|
||||
|
||||
const data = await this.browser.func.getDashboardData(page)
|
||||
|
||||
// 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')
|
||||
|
||||
// Close mobile browser
|
||||
return await browser.close()
|
||||
}
|
||||
|
||||
// Do mobile searches
|
||||
if (this.config.workers.doMobileSearch) {
|
||||
await this.activities.doSearch(page, data, true)
|
||||
}
|
||||
|
||||
// Fetch new points
|
||||
const earnablePoints = await this.browser.func.getEarnablePoints(data, page)
|
||||
|
||||
// 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`)
|
||||
|
||||
// Close mobile browser
|
||||
await browser.close()
|
||||
}
|
||||
}
|
||||
|
||||
const bot = new MicrosoftRewardsBot()
|
||||
export { executionContext }
|
||||
|
||||
// Initialize accounts first and then start the bot
|
||||
bot.initialize().then(() => {
|
||||
bot.run()
|
||||
})
|
||||
async function main(): Promise<void> {
|
||||
// Check before doing anything
|
||||
checkNodeVersion()
|
||||
const rewardsBot = new MicrosoftRewardsBot()
|
||||
|
||||
process.on('beforeExit', () => {
|
||||
void flushAllWebhooks()
|
||||
})
|
||||
process.on('SIGINT', async () => {
|
||||
rewardsBot.logger.warn('main', 'PROCESS', 'SIGINT received, flushing and exiting...')
|
||||
await flushAllWebhooks()
|
||||
process.exit(130)
|
||||
})
|
||||
process.on('SIGTERM', async () => {
|
||||
rewardsBot.logger.warn('main', 'PROCESS', 'SIGTERM received, flushing and exiting...')
|
||||
await flushAllWebhooks()
|
||||
process.exit(143)
|
||||
})
|
||||
process.on('uncaughtException', async error => {
|
||||
rewardsBot.logger.error('main', 'UNCAUGHT-EXCEPTION', error)
|
||||
await flushAllWebhooks()
|
||||
process.exit(1)
|
||||
})
|
||||
process.on('unhandledRejection', async reason => {
|
||||
rewardsBot.logger.error('main', 'UNHANDLED-REJECTION', reason as Error)
|
||||
await flushAllWebhooks()
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
try {
|
||||
await rewardsBot.initialize()
|
||||
await rewardsBot.run()
|
||||
} catch (error) {
|
||||
rewardsBot.logger.error('main', 'MAIN-ERROR', error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(async error => {
|
||||
const tmpBot = new MicrosoftRewardsBot()
|
||||
tmpBot.logger.error('main', 'MAIN-ERROR', error as Error)
|
||||
await flushAllWebhooks()
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
export interface Account {
|
||||
email: string;
|
||||
password: string;
|
||||
proxy: AccountProxy;
|
||||
email: string
|
||||
password: string
|
||||
totpSecret?: string
|
||||
recoveryEmail: string
|
||||
geoLocale: 'auto' | string
|
||||
langCode: 'en' | string
|
||||
proxy: AccountProxy
|
||||
saveFingerprint: ConfigSaveFingerprint
|
||||
}
|
||||
|
||||
export interface AccountProxy {
|
||||
url: string;
|
||||
port: number;
|
||||
password: string;
|
||||
username: string;
|
||||
}
|
||||
proxyAxios: boolean
|
||||
url: string
|
||||
port: number
|
||||
password: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface ConfigSaveFingerprint {
|
||||
mobile: boolean
|
||||
desktop: boolean
|
||||
}
|
||||
|
||||
105
src/interface/AppDashBoardData.ts
Normal file
105
src/interface/AppDashBoardData.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export interface AppDashboardData {
|
||||
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: unknown[]
|
||||
rebateProfile: null
|
||||
rebatePayouts: null
|
||||
giveProfile: null
|
||||
autoRedeemProfile: null
|
||||
autoRedeemItem: null
|
||||
thirdPartyProfile: null
|
||||
notifications: null
|
||||
waitlist: null
|
||||
autoOpenFlyout: null
|
||||
coupons: null
|
||||
recommendedAffordableCatalog: null
|
||||
generativeAICreditsBalance: null
|
||||
requestCountryCatalog: null
|
||||
donationCatalog: null
|
||||
}
|
||||
|
||||
export interface GoalItem {
|
||||
name: string
|
||||
provider: string
|
||||
price: number
|
||||
attributes: GoalItemAttributes
|
||||
config: Config
|
||||
}
|
||||
|
||||
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
|
||||
points_destination: string
|
||||
points_source: string
|
||||
Remarks: string
|
||||
ShortText: string
|
||||
showcase: string
|
||||
small_image: string
|
||||
title: string
|
||||
cimsid: string
|
||||
user_defined_goal: string
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
isHidden: string
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
ruid: string
|
||||
attributes: ProfileAttributes
|
||||
offline_attributes: OfflineAttributes
|
||||
}
|
||||
|
||||
export interface ProfileAttributes {
|
||||
ismsaautojoined: string
|
||||
created: Date
|
||||
creative: string
|
||||
publisher: string
|
||||
program: string
|
||||
country: string
|
||||
target: string
|
||||
epuid: string
|
||||
level: string
|
||||
level_upd: Date
|
||||
iris_segmentation: string
|
||||
iris_segmentation_upd: Date
|
||||
waitlistattributes: string
|
||||
waitlistattributes_upd: Date
|
||||
}
|
||||
|
||||
export interface OfflineAttributes {}
|
||||
|
||||
export interface Promotion {
|
||||
name: string
|
||||
priority: number
|
||||
attributes: { [key: string]: string }
|
||||
tags: string[]
|
||||
}
|
||||
225
src/interface/AppUserData.ts
Normal file
225
src/interface/AppUserData.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
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'
|
||||
}
|
||||
@@ -1,35 +1,79 @@
|
||||
export interface Config {
|
||||
baseURL: string;
|
||||
sessionPath: string;
|
||||
headless: boolean;
|
||||
runOnZeroPoints: boolean;
|
||||
clusters: number;
|
||||
workers: Workers;
|
||||
searchSettings: SearchSettings;
|
||||
webhook: Webhook;
|
||||
baseURL: string
|
||||
sessionPath: string
|
||||
headless: boolean
|
||||
runOnZeroPoints: boolean
|
||||
clusters: number
|
||||
errorDiagnostics: boolean
|
||||
workers: ConfigWorkers
|
||||
searchOnBingLocalQueries: boolean
|
||||
globalTimeout: number | string
|
||||
searchSettings: ConfigSearchSettings
|
||||
debugLogs: boolean
|
||||
proxy: ConfigProxy
|
||||
consoleLogFilter: LogFilter
|
||||
webhook: ConfigWebhook
|
||||
}
|
||||
|
||||
export interface SearchSettings {
|
||||
useGeoLocaleQueries: boolean;
|
||||
scrollRandomResults: boolean;
|
||||
clickRandomResults: boolean;
|
||||
searchDelay: SearchDelay;
|
||||
export type QueryEngine = 'google' | 'wikipedia' | 'reddit' | 'local'
|
||||
|
||||
export interface ConfigSearchSettings {
|
||||
scrollRandomResults: boolean
|
||||
clickRandomResults: boolean
|
||||
parallelSearching: boolean
|
||||
queryEngines: QueryEngine[]
|
||||
searchResultVisitTime: number | string
|
||||
searchDelay: ConfigDelay
|
||||
readDelay: ConfigDelay
|
||||
}
|
||||
|
||||
export interface SearchDelay {
|
||||
min: number;
|
||||
max: number;
|
||||
export interface ConfigDelay {
|
||||
min: number | string
|
||||
max: number | string
|
||||
}
|
||||
|
||||
export interface Webhook {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
export interface ConfigProxy {
|
||||
queryEngine: boolean
|
||||
}
|
||||
|
||||
export interface Workers {
|
||||
doDailySet: boolean;
|
||||
doMorePromotions: boolean;
|
||||
doPunchCards: boolean;
|
||||
doDesktopSearch: boolean;
|
||||
doMobileSearch: boolean;
|
||||
export interface ConfigWorkers {
|
||||
doDailySet: boolean
|
||||
doSpecialPromotions: boolean
|
||||
doMorePromotions: boolean
|
||||
doPunchCards: boolean
|
||||
doAppPromotions: boolean
|
||||
doDesktopSearch: boolean
|
||||
doMobileSearch: boolean
|
||||
doDailyCheckIn: boolean
|
||||
doReadToEarn: boolean
|
||||
}
|
||||
|
||||
// Webhooks
|
||||
export interface ConfigWebhook {
|
||||
discord?: WebhookDiscordConfig
|
||||
ntfy?: WebhookNtfyConfig
|
||||
webhookLogFilter: LogFilter
|
||||
}
|
||||
|
||||
export interface LogFilter {
|
||||
enabled: boolean
|
||||
mode: 'whitelist' | 'blacklist'
|
||||
levels?: Array<'debug' | 'info' | 'warn' | 'error'>
|
||||
keywords?: string[]
|
||||
regexPatterns?: string[]
|
||||
}
|
||||
|
||||
export interface WebhookDiscordConfig {
|
||||
enabled: boolean
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface WebhookNtfyConfig {
|
||||
enabled?: boolean
|
||||
url: string
|
||||
topic?: string
|
||||
token?: string
|
||||
title?: string
|
||||
tags?: string[]
|
||||
priority?: 1 | 2 | 3 | 4 | 5 // 5 highest (important)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,44 +0,0 @@
|
||||
export interface GoogleTrends {
|
||||
default: Default;
|
||||
}
|
||||
|
||||
export interface Default {
|
||||
trendingSearchesDays: TrendingSearchesDay[];
|
||||
endDateForNextRequest: string;
|
||||
rssFeedPageUrl: string;
|
||||
}
|
||||
|
||||
export interface TrendingSearchesDay {
|
||||
date: string;
|
||||
formattedDate: string;
|
||||
trendingSearches: TrendingSearch[];
|
||||
}
|
||||
|
||||
export interface TrendingSearch {
|
||||
title: Title;
|
||||
formattedTraffic: string;
|
||||
relatedQueries: Title[];
|
||||
image: Image;
|
||||
articles: Article[];
|
||||
shareUrl: string;
|
||||
}
|
||||
|
||||
export interface Article {
|
||||
title: string;
|
||||
timeAgo: string;
|
||||
source: string;
|
||||
image?: Image;
|
||||
url: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
export interface Image {
|
||||
newsUrl: string;
|
||||
source: string;
|
||||
imageUrl: string;
|
||||
}
|
||||
|
||||
export interface Title {
|
||||
query: string;
|
||||
exploreLink: string;
|
||||
}
|
||||
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
|
||||
}
|
||||
20
src/interface/Points.ts
Normal file
20
src/interface/Points.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface BrowserEarnablePoints {
|
||||
desktopSearchPoints: number
|
||||
mobileSearchPoints: number
|
||||
dailySetPoints: number
|
||||
morePromotionsPoints: number
|
||||
totalEarnablePoints: number
|
||||
}
|
||||
|
||||
export interface AppEarnablePoints {
|
||||
readToEarn: number
|
||||
checkIn: number
|
||||
totalEarnablePoints: number
|
||||
}
|
||||
|
||||
export interface MissingSearchPoints {
|
||||
mobilePoints: number
|
||||
desktopPoints: number
|
||||
edgePoints: number
|
||||
totalPoints: number
|
||||
}
|
||||
@@ -1,50 +1,50 @@
|
||||
export interface QuizData {
|
||||
offerId: string;
|
||||
quizId: string;
|
||||
quizCategory: string;
|
||||
IsCurrentQuestionCompleted: boolean;
|
||||
quizRenderSummaryPage: boolean;
|
||||
resetQuiz: boolean;
|
||||
userClickedOnHint: boolean;
|
||||
isDemoEnabled: boolean;
|
||||
correctAnswer: string;
|
||||
isMultiChoiceQuizType: boolean;
|
||||
isPutInOrderQuizType: boolean;
|
||||
isListicleQuizType: boolean;
|
||||
isWOTQuizType: boolean;
|
||||
isBugsForRewardsQuizType: boolean;
|
||||
currentQuestionNumber: number;
|
||||
maxQuestions: number;
|
||||
resetTrackingCounters: boolean;
|
||||
showWelcomePanel: boolean;
|
||||
isAjaxCall: boolean;
|
||||
showHint: boolean;
|
||||
numberOfOptions: number;
|
||||
isMobile: boolean;
|
||||
inRewardsMode: boolean;
|
||||
enableDailySetWelcomePane: boolean;
|
||||
enableDailySetNonWelcomePane: boolean;
|
||||
isDailySetUrlOffer: boolean;
|
||||
isDailySetFlightEnabled: boolean;
|
||||
dailySetUrlOfferId: string;
|
||||
earnedCredits: number;
|
||||
maxCredits: number;
|
||||
creditsPerQuestion: number;
|
||||
userAlreadyClickedOptions: number;
|
||||
hasUserClickedOnOption: boolean;
|
||||
recentAnswerChoice: string;
|
||||
sessionTimerSeconds: string;
|
||||
isOverlayMinimized: number;
|
||||
ScreenReaderMsgOnMove: string;
|
||||
ScreenReaderMsgOnDrop: string;
|
||||
IsPartialPointsEnabled: boolean;
|
||||
PrioritizeUrlOverCookies: boolean;
|
||||
UseNewReportActivityAPI: boolean;
|
||||
CorrectlyAnsweredQuestionCount: number;
|
||||
showJoinRewardsPage: boolean;
|
||||
CorrectOptionAnswer_WOT: string;
|
||||
WrongOptionAnswer_WOT: string;
|
||||
enableSlideAnimation: boolean;
|
||||
ariaLoggingEnabled: boolean;
|
||||
UseQuestionIndexInActivityId: boolean;
|
||||
offerId: string
|
||||
quizId: string
|
||||
quizCategory: string
|
||||
IsCurrentQuestionCompleted: boolean
|
||||
quizRenderSummaryPage: boolean
|
||||
resetQuiz: boolean
|
||||
userClickedOnHint: boolean
|
||||
isDemoEnabled: boolean
|
||||
correctAnswer: string
|
||||
isMultiChoiceQuizType: boolean
|
||||
isPutInOrderQuizType: boolean
|
||||
isListicleQuizType: boolean
|
||||
isWOTQuizType: boolean
|
||||
isBugsForRewardsQuizType: boolean
|
||||
currentQuestionNumber: number
|
||||
maxQuestions: number
|
||||
resetTrackingCounters: boolean
|
||||
showWelcomePanel: boolean
|
||||
isAjaxCall: boolean
|
||||
showHint: boolean
|
||||
numberOfOptions: number
|
||||
isMobile: boolean
|
||||
inRewardsMode: boolean
|
||||
enableDailySetWelcomePane: boolean
|
||||
enableDailySetNonWelcomePane: boolean
|
||||
isDailySetUrlOffer: boolean
|
||||
isDailySetFlightEnabled: boolean
|
||||
dailySetUrlOfferId: string
|
||||
earnedCredits: number
|
||||
maxCredits: number
|
||||
creditsPerQuestion: number
|
||||
userAlreadyClickedOptions: number
|
||||
hasUserClickedOnOption: boolean
|
||||
recentAnswerChoice: string
|
||||
sessionTimerSeconds: string
|
||||
isOverlayMinimized: number
|
||||
ScreenReaderMsgOnMove: string
|
||||
ScreenReaderMsgOnDrop: string
|
||||
IsPartialPointsEnabled: boolean
|
||||
PrioritizeUrlOverCookies: boolean
|
||||
UseNewReportActivityAPI: boolean
|
||||
CorrectlyAnsweredQuestionCount: number
|
||||
showJoinRewardsPage: boolean
|
||||
CorrectOptionAnswer_WOT: string
|
||||
WrongOptionAnswer_WOT: string
|
||||
enableSlideAnimation: boolean
|
||||
ariaLoggingEnabled: boolean
|
||||
UseQuestionIndexInActivityId: boolean
|
||||
}
|
||||
|
||||
@@ -1,4 +1,116 @@
|
||||
// Google Trends
|
||||
export type GoogleTrendsResponse = [string, [string, ...null[], [string, ...string[]]][]]
|
||||
|
||||
export interface GoogleSearch {
|
||||
topic: string;
|
||||
related: string[];
|
||||
}
|
||||
topic: string
|
||||
related: string[]
|
||||
}
|
||||
|
||||
// Bing Suggestions
|
||||
export interface BingSuggestionResponse {
|
||||
_type: string
|
||||
instrumentation: BingInstrumentation
|
||||
queryContext: BingQueryContext
|
||||
suggestionGroups: BingSuggestionGroup[]
|
||||
}
|
||||
|
||||
export interface BingInstrumentation {
|
||||
_type: string
|
||||
pingUrlBase: string
|
||||
pageLoadPingUrl: string
|
||||
llmPingUrlBase: string
|
||||
llmLogPingUrlBase: string
|
||||
}
|
||||
|
||||
export interface BingQueryContext {
|
||||
originalQuery: string
|
||||
}
|
||||
|
||||
export interface BingSuggestionGroup {
|
||||
name: string
|
||||
searchSuggestions: BingSearchSuggestion[]
|
||||
}
|
||||
|
||||
export interface BingSearchSuggestion {
|
||||
url: string
|
||||
urlPingSuffix: string
|
||||
displayText: string
|
||||
query: string
|
||||
result?: BingResult[]
|
||||
searchKind?: string
|
||||
}
|
||||
|
||||
export interface BingResult {
|
||||
id: string
|
||||
readLink: string
|
||||
readLinkPingSuffix: string
|
||||
webSearchUrl: string
|
||||
webSearchUrlPingSuffix: string
|
||||
name: string
|
||||
image: BingSuggestionImage
|
||||
description: string
|
||||
entityPresentationInfo: BingEntityPresentationInfo
|
||||
bingId: string
|
||||
}
|
||||
|
||||
export interface BingEntityPresentationInfo {
|
||||
entityScenario: string
|
||||
entityTypeDisplayHint: string
|
||||
query: string
|
||||
}
|
||||
|
||||
export interface BingSuggestionImage {
|
||||
thumbnailUrl: string
|
||||
hostPageUrl: string
|
||||
hostPageUrlPingSuffix: string
|
||||
width: number
|
||||
height: number
|
||||
sourceWidth: number
|
||||
sourceHeight: number
|
||||
}
|
||||
|
||||
// Bing Tending Topics
|
||||
export interface BingTrendingTopicsResponse {
|
||||
_type: string
|
||||
instrumentation: BingInstrumentation
|
||||
value: BingValue[]
|
||||
}
|
||||
|
||||
export interface BingValue {
|
||||
webSearchUrl: string
|
||||
webSearchUrlPingSuffix: string
|
||||
name: string
|
||||
image: BingTrendingImage
|
||||
isBreakingNews: boolean
|
||||
query: BingTrendingQuery
|
||||
newsSearchUrl: string
|
||||
newsSearchUrlPingSuffix: string
|
||||
}
|
||||
|
||||
export interface BingTrendingImage {
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface BingTrendingQuery {
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface WikipediaTopResponse {
|
||||
items: Array<{
|
||||
articles: Array<{
|
||||
article: string
|
||||
views: number
|
||||
}>
|
||||
}>
|
||||
}
|
||||
|
||||
export interface RedditListing {
|
||||
data: {
|
||||
children: Array<{
|
||||
data: {
|
||||
title: string
|
||||
over_18: boolean
|
||||
}
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,37 @@
|
||||
// Chrome Product Data
|
||||
export interface ChromeVersion {
|
||||
timestamp: Date;
|
||||
channels: Channels;
|
||||
timestamp: Date
|
||||
channels: Channels
|
||||
}
|
||||
|
||||
export interface Channels {
|
||||
Stable: Beta;
|
||||
Beta: Beta;
|
||||
Dev: Beta;
|
||||
Canary: Beta;
|
||||
Stable: Beta
|
||||
Beta: Beta
|
||||
Dev: Beta
|
||||
Canary: Beta
|
||||
}
|
||||
|
||||
export interface Beta {
|
||||
channel: string;
|
||||
version: string;
|
||||
revision: string;
|
||||
channel: string
|
||||
version: string
|
||||
revision: string
|
||||
}
|
||||
|
||||
// Edge Product Data
|
||||
export interface EdgeVersion {
|
||||
Product: string;
|
||||
Releases: Release[];
|
||||
Product: string
|
||||
Releases: Release[]
|
||||
}
|
||||
|
||||
export interface Release {
|
||||
ReleaseId: number;
|
||||
Platform: Platform;
|
||||
Architecture: Architecture;
|
||||
CVEs: string[];
|
||||
ProductVersion: string;
|
||||
Artifacts: Artifact[];
|
||||
PublishedTime: Date;
|
||||
ExpectedExpiryDate: Date;
|
||||
ReleaseId: number
|
||||
Platform: Platform
|
||||
Architecture: Architecture
|
||||
CVEs: string[]
|
||||
ProductVersion: string
|
||||
Artifacts: Artifact[]
|
||||
PublishedTime: Date
|
||||
ExpectedExpiryDate: Date
|
||||
}
|
||||
|
||||
export enum Architecture {
|
||||
@@ -42,11 +42,11 @@ export enum Architecture {
|
||||
}
|
||||
|
||||
export interface Artifact {
|
||||
ArtifactName: string;
|
||||
Location: string;
|
||||
Hash: string;
|
||||
HashAlgorithm: HashAlgorithm;
|
||||
SizeInBytes: number;
|
||||
ArtifactName: string
|
||||
Location: string
|
||||
Hash: string
|
||||
HashAlgorithm: HashAlgorithm
|
||||
SizeInBytes: number
|
||||
}
|
||||
|
||||
export enum HashAlgorithm {
|
||||
@@ -59,4 +59,4 @@ export enum Platform {
|
||||
Linux = 'Linux',
|
||||
MACOS = 'MacOS',
|
||||
Windows = 'Windows'
|
||||
}
|
||||
}
|
||||
|
||||
43
src/interface/XboxDashboardData.ts
Normal file
43
src/interface/XboxDashboardData.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface XboxDashboardData {
|
||||
response: Response
|
||||
correlationId: string
|
||||
code: number
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
profile: null
|
||||
balance: number
|
||||
counters: { [key: string]: string }
|
||||
promotions: Promotion[]
|
||||
catalog: null
|
||||
goal_item: null
|
||||
activities: null
|
||||
cashback: null
|
||||
orders: null
|
||||
rebateProfile: null
|
||||
rebatePayouts: null
|
||||
giveProfile: null
|
||||
autoRedeemProfile: null
|
||||
autoRedeemItem: null
|
||||
thirdPartyProfile: null
|
||||
notifications: null
|
||||
waitlist: null
|
||||
autoOpenFlyout: null
|
||||
coupons: null
|
||||
recommendedAffordableCatalog: null
|
||||
generativeAICreditsBalance: null
|
||||
requestCountryCatalog: null
|
||||
donationCatalog: null
|
||||
}
|
||||
|
||||
export interface Promotion {
|
||||
name: string
|
||||
priority: number
|
||||
attributes: { [key: string]: string }
|
||||
tags: Tag[]
|
||||
}
|
||||
|
||||
export enum Tag {
|
||||
ExcludeHidden = 'exclude_hidden',
|
||||
NonGlobalConfig = 'non_global_config'
|
||||
}
|
||||
50
src/logging/Discord.ts
Normal file
50
src/logging/Discord.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import axios, { AxiosRequestConfig } from 'axios'
|
||||
import PQueue from 'p-queue'
|
||||
import type { LogLevel } from './Logger'
|
||||
|
||||
const DISCORD_LIMIT = 2000
|
||||
|
||||
export interface DiscordConfig {
|
||||
enabled?: boolean
|
||||
url: string
|
||||
}
|
||||
|
||||
const discordQueue = new PQueue({
|
||||
interval: 1000,
|
||||
intervalCap: 2,
|
||||
carryoverConcurrencyCount: true
|
||||
})
|
||||
|
||||
function truncate(text: string) {
|
||||
return text.length <= DISCORD_LIMIT ? text : text.slice(0, DISCORD_LIMIT - 14) + ' …(truncated)'
|
||||
}
|
||||
|
||||
export async function sendDiscord(discordUrl: string, content: string, level: LogLevel): Promise<void> {
|
||||
if (!discordUrl) return
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
method: 'POST',
|
||||
url: discordUrl,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: { content: truncate(content), allowed_mentions: { parse: [] } },
|
||||
timeout: 10000
|
||||
}
|
||||
|
||||
await discordQueue.add(async () => {
|
||||
try {
|
||||
await axios(request)
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status
|
||||
if (status === 429) return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function flushDiscordQueue(timeoutMs = 5000): Promise<void> {
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await discordQueue.onIdle()
|
||||
})(),
|
||||
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('discord flush timeout')), timeoutMs))
|
||||
]).catch(() => {})
|
||||
}
|
||||
189
src/logging/Logger.ts
Normal file
189
src/logging/Logger.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import chalk from 'chalk'
|
||||
import cluster from 'cluster'
|
||||
import { sendDiscord } from './Discord'
|
||||
import { sendNtfy } from './Ntfy'
|
||||
import type { MicrosoftRewardsBot } from '../index'
|
||||
import { errorDiagnostic } from '../util/ErrorDiagnostic'
|
||||
import type { LogFilter } from '../interface/Config'
|
||||
|
||||
export type Platform = boolean | 'main'
|
||||
export type LogLevel = 'info' | 'warn' | 'error' | 'debug'
|
||||
export type ColorKey = keyof typeof chalk
|
||||
export interface IpcLog {
|
||||
content: string
|
||||
level: LogLevel
|
||||
}
|
||||
|
||||
type ChalkFn = (msg: string) => string
|
||||
|
||||
function platformText(platform: Platform): string {
|
||||
return platform === 'main' ? 'MAIN' : platform ? 'MOBILE' : 'DESKTOP'
|
||||
}
|
||||
|
||||
function platformBadge(platform: Platform): string {
|
||||
return platform === 'main' ? chalk.bgCyan('MAIN') : platform ? chalk.bgBlue('MOBILE') : chalk.bgMagenta('DESKTOP')
|
||||
}
|
||||
|
||||
function getColorFn(color?: ColorKey): ChalkFn | null {
|
||||
return color && typeof chalk[color] === 'function' ? (chalk[color] as ChalkFn) : null
|
||||
}
|
||||
|
||||
function consoleOut(level: LogLevel, msg: string, chalkFn: ChalkFn | null): void {
|
||||
const out = chalkFn ? chalkFn(msg) : msg
|
||||
switch (level) {
|
||||
case 'warn':
|
||||
return console.warn(out)
|
||||
case 'error':
|
||||
return console.error(out)
|
||||
default:
|
||||
return console.log(out)
|
||||
}
|
||||
}
|
||||
|
||||
function formatMessage(message: string | Error): string {
|
||||
return message instanceof Error ? `${message.message}\n${message.stack || ''}` : message
|
||||
}
|
||||
|
||||
export class Logger {
|
||||
constructor(private bot: MicrosoftRewardsBot) {}
|
||||
|
||||
info(isMobile: Platform, title: string, message: string, color?: ColorKey) {
|
||||
return this.baseLog('info', isMobile, title, message, color)
|
||||
}
|
||||
|
||||
warn(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) {
|
||||
return this.baseLog('warn', isMobile, title, message, color)
|
||||
}
|
||||
|
||||
error(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) {
|
||||
return this.baseLog('error', isMobile, title, message, color)
|
||||
}
|
||||
|
||||
debug(isMobile: Platform, title: string, message: string | Error, color?: ColorKey) {
|
||||
return this.baseLog('debug', isMobile, title, message, color)
|
||||
}
|
||||
|
||||
private baseLog(
|
||||
level: LogLevel,
|
||||
isMobile: Platform,
|
||||
title: string,
|
||||
message: string | Error,
|
||||
color?: ColorKey
|
||||
): void {
|
||||
const now = new Date().toLocaleString()
|
||||
const formatted = formatMessage(message)
|
||||
|
||||
const userName = this.bot.userData.userName ? this.bot.userData.userName : 'MAIN'
|
||||
|
||||
const levelTag = level.toUpperCase()
|
||||
const cleanMsg = `[${now}] [${userName}] [${levelTag}] ${platformText(isMobile)} [${title}] ${formatted}`
|
||||
|
||||
const config = this.bot.config
|
||||
|
||||
if (level === 'debug' && !config.debugLogs && !process.argv.includes('-dev')) {
|
||||
return
|
||||
}
|
||||
|
||||
const badge = platformBadge(isMobile)
|
||||
const consoleStr = `[${now}] [${userName}] [${levelTag}] ${badge} [${title}] ${formatted}`
|
||||
|
||||
let logColor: ColorKey | undefined = color
|
||||
|
||||
if (!logColor) {
|
||||
switch (level) {
|
||||
case 'error':
|
||||
logColor = 'red'
|
||||
break
|
||||
case 'warn':
|
||||
logColor = 'yellow'
|
||||
break
|
||||
case 'debug':
|
||||
logColor = 'magenta'
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (level === 'error' && config.errorDiagnostics) {
|
||||
const page = this.bot.isMobile ? this.bot.mainMobilePage : this.bot.mainDesktopPage
|
||||
const error = message instanceof Error ? message : new Error(String(message))
|
||||
errorDiagnostic(page, error)
|
||||
}
|
||||
|
||||
const consoleAllowed = this.shouldPassFilter(config.consoleLogFilter, level, cleanMsg)
|
||||
const webhookAllowed = this.shouldPassFilter(config.webhook.webhookLogFilter, level, cleanMsg)
|
||||
|
||||
if (consoleAllowed) {
|
||||
consoleOut(level, consoleStr, getColorFn(logColor))
|
||||
}
|
||||
|
||||
if (!webhookAllowed) {
|
||||
return
|
||||
}
|
||||
|
||||
if (cluster.isPrimary) {
|
||||
if (config.webhook.discord?.enabled && config.webhook.discord.url) {
|
||||
if (level === 'debug') return
|
||||
sendDiscord(config.webhook.discord.url, cleanMsg, level)
|
||||
}
|
||||
|
||||
if (config.webhook.ntfy?.enabled && config.webhook.ntfy.url) {
|
||||
if (level === 'debug') return
|
||||
sendNtfy(config.webhook.ntfy, cleanMsg, level)
|
||||
}
|
||||
} else {
|
||||
process.send?.({ __ipcLog: { content: cleanMsg, level } })
|
||||
}
|
||||
}
|
||||
|
||||
private shouldPassFilter(filter: LogFilter | undefined, level: LogLevel, message: string): boolean {
|
||||
// If disabled or not, let all logs pass
|
||||
if (!filter || !filter.enabled) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Always log error levelo logs, remove these lines to disable this!
|
||||
if (level === 'error') {
|
||||
return true
|
||||
}
|
||||
|
||||
const { mode, levels, keywords, regexPatterns } = filter
|
||||
|
||||
const hasLevelRule = Array.isArray(levels) && levels.length > 0
|
||||
const hasKeywordRule = Array.isArray(keywords) && keywords.length > 0
|
||||
const hasPatternRule = Array.isArray(regexPatterns) && regexPatterns.length > 0
|
||||
|
||||
if (!hasLevelRule && !hasKeywordRule && !hasPatternRule) {
|
||||
return mode === 'blacklist'
|
||||
}
|
||||
|
||||
const lowerMessage = message.toLowerCase()
|
||||
let isMatch = false
|
||||
|
||||
if (hasLevelRule && levels!.includes(level)) {
|
||||
isMatch = true
|
||||
}
|
||||
|
||||
if (!isMatch && hasKeywordRule) {
|
||||
if (keywords!.some(k => lowerMessage.includes(k.toLowerCase()))) {
|
||||
isMatch = true
|
||||
}
|
||||
}
|
||||
|
||||
// Fancy regex filtering if set!
|
||||
if (!isMatch && hasPatternRule) {
|
||||
for (const pattern of regexPatterns!) {
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'i')
|
||||
if (regex.test(message)) {
|
||||
isMatch = true
|
||||
break
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
return mode === 'whitelist' ? isMatch : !isMatch
|
||||
}
|
||||
}
|
||||
61
src/logging/Ntfy.ts
Normal file
61
src/logging/Ntfy.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import axios, { AxiosRequestConfig } from 'axios'
|
||||
import PQueue from 'p-queue'
|
||||
import type { WebhookNtfyConfig } from '../interface/Config'
|
||||
import type { LogLevel } from './Logger'
|
||||
|
||||
const ntfyQueue = new PQueue({
|
||||
interval: 1000,
|
||||
intervalCap: 2,
|
||||
carryoverConcurrencyCount: true
|
||||
})
|
||||
|
||||
export async function sendNtfy(config: WebhookNtfyConfig, content: string, level: LogLevel): Promise<void> {
|
||||
if (!config?.url) return
|
||||
|
||||
switch (level) {
|
||||
case 'error':
|
||||
config.priority = 5 // Highest
|
||||
break
|
||||
|
||||
case 'warn':
|
||||
config.priority = 4
|
||||
break
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { 'Content-Type': 'text/plain' }
|
||||
if (config.title) headers['Title'] = config.title
|
||||
if (config.tags?.length) headers['Tags'] = config.tags.join(',')
|
||||
if (config.priority) headers['Priority'] = String(config.priority)
|
||||
if (config.token) headers['Authorization'] = `Bearer ${config.token}`
|
||||
|
||||
const url = config.topic ? `${config.url}/${config.topic}` : config.url
|
||||
|
||||
const request: AxiosRequestConfig = {
|
||||
method: 'POST',
|
||||
url: url,
|
||||
headers,
|
||||
data: content,
|
||||
timeout: 10000
|
||||
}
|
||||
|
||||
await ntfyQueue.add(async () => {
|
||||
try {
|
||||
await axios(request)
|
||||
} catch (err: any) {
|
||||
const status = err?.response?.status
|
||||
if (status === 429) return
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function flushNtfyQueue(timeoutMs = 5000): Promise<void> {
|
||||
await Promise.race([
|
||||
(async () => {
|
||||
await ntfyQueue.onIdle()
|
||||
})(),
|
||||
new Promise<void>((_, reject) => setTimeout(() => reject(new Error('ntfy flush timeout')), timeoutMs))
|
||||
]).catch(() => {})
|
||||
}
|
||||
95
src/util/Axios.ts
Normal file
95
src/util/Axios.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import axiosRetry from 'axios-retry'
|
||||
import { HttpProxyAgent } from 'http-proxy-agent'
|
||||
import { HttpsProxyAgent } from 'https-proxy-agent'
|
||||
import { SocksProxyAgent } from 'socks-proxy-agent'
|
||||
import { URL } from 'url'
|
||||
import type { AccountProxy } from '../interface/Account'
|
||||
|
||||
class AxiosClient {
|
||||
private instance: AxiosInstance
|
||||
private account: AccountProxy
|
||||
|
||||
constructor(account: AccountProxy) {
|
||||
this.account = account
|
||||
|
||||
this.instance = axios.create({
|
||||
timeout: 20000
|
||||
})
|
||||
|
||||
if (this.account.url && this.account.proxyAxios) {
|
||||
const agent = this.getAgentForProxy(this.account)
|
||||
this.instance.defaults.httpAgent = agent
|
||||
this.instance.defaults.httpsAgent = agent
|
||||
}
|
||||
|
||||
axiosRetry(this.instance, {
|
||||
retries: 5,
|
||||
retryDelay: axiosRetry.exponentialDelay,
|
||||
shouldResetTimeout: true,
|
||||
retryCondition: error => {
|
||||
if (axiosRetry.isNetworkError(error)) return true
|
||||
if (!error.response) return true
|
||||
|
||||
const status = error.response.status
|
||||
return status === 429 || (status >= 500 && status <= 599)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private getAgentForProxy(
|
||||
proxyConfig: AccountProxy
|
||||
): HttpProxyAgent<string> | HttpsProxyAgent<string> | SocksProxyAgent {
|
||||
const { url: baseUrl, port, username, password } = proxyConfig
|
||||
|
||||
let urlObj: URL
|
||||
try {
|
||||
urlObj = new URL(baseUrl)
|
||||
} catch (e) {
|
||||
try {
|
||||
urlObj = new URL(`http://${baseUrl}`)
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid proxy URL format: ${baseUrl}`)
|
||||
}
|
||||
}
|
||||
|
||||
const protocol = urlObj.protocol.toLowerCase()
|
||||
let proxyUrl: string
|
||||
|
||||
if (username && password) {
|
||||
urlObj.username = encodeURIComponent(username)
|
||||
urlObj.password = encodeURIComponent(password)
|
||||
urlObj.port = port.toString()
|
||||
proxyUrl = urlObj.toString()
|
||||
} else {
|
||||
proxyUrl = `${protocol}//${urlObj.hostname}:${port}`
|
||||
}
|
||||
|
||||
switch (protocol) {
|
||||
case 'http:':
|
||||
return new HttpProxyAgent(proxyUrl)
|
||||
case 'https:':
|
||||
return new HttpsProxyAgent(proxyUrl)
|
||||
case 'socks4:':
|
||||
case 'socks5:':
|
||||
return new SocksProxyAgent(proxyUrl)
|
||||
default:
|
||||
throw new Error(`Unsupported proxy protocol: ${protocol}. Only HTTP(S) and SOCKS4/5 are supported!`)
|
||||
}
|
||||
}
|
||||
|
||||
public async request(config: AxiosRequestConfig, bypassProxy = false): Promise<AxiosResponse> {
|
||||
if (bypassProxy) {
|
||||
const bypassInstance = axios.create()
|
||||
axiosRetry(bypassInstance, {
|
||||
retries: 3,
|
||||
retryDelay: axiosRetry.exponentialDelay
|
||||
})
|
||||
return bypassInstance.request(config)
|
||||
}
|
||||
|
||||
return this.instance.request(config)
|
||||
}
|
||||
}
|
||||
|
||||
export default AxiosClient
|
||||
46
src/util/ErrorDiagnostic.ts
Normal file
46
src/util/ErrorDiagnostic.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import type { Page } from 'patchright'
|
||||
|
||||
export async function errorDiagnostic(page: Page, error: Error): Promise<void> {
|
||||
try {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
const folderName = `error-${timestamp}`
|
||||
const outputDir = path.join(process.cwd(), 'diagnostics', folderName)
|
||||
|
||||
if (!page) {
|
||||
return
|
||||
}
|
||||
|
||||
if (page.isClosed()) {
|
||||
return
|
||||
}
|
||||
|
||||
// Error log content
|
||||
const errorLog = `
|
||||
Name: ${error.name}
|
||||
Message: ${error.message}
|
||||
Timestamp: ${new Date().toISOString()}
|
||||
---------------------------------------------------
|
||||
Stack Trace:
|
||||
${error.stack || 'No stack trace available'}
|
||||
`.trim()
|
||||
|
||||
const [htmlContent, screenshotBuffer] = await Promise.all([
|
||||
page.content(),
|
||||
page.screenshot({ fullPage: true, type: 'png' })
|
||||
])
|
||||
|
||||
await fs.mkdir(outputDir, { recursive: true })
|
||||
|
||||
await Promise.all([
|
||||
fs.writeFile(path.join(outputDir, 'dump.html'), htmlContent),
|
||||
fs.writeFile(path.join(outputDir, 'screenshot.png'), screenshotBuffer),
|
||||
fs.writeFile(path.join(outputDir, 'error.txt'), errorLog)
|
||||
])
|
||||
|
||||
console.log(`Diagnostics saved to: ${outputDir}`)
|
||||
} catch (error) {
|
||||
console.error('Unable to create error diagnostics:', error)
|
||||
}
|
||||
}
|
||||
106
src/util/Load.ts
106
src/util/Load.ts
@@ -1,23 +1,29 @@
|
||||
import type { Cookie } from 'patchright'
|
||||
import type { BrowserFingerprintWithHeaders } from 'fingerprint-generator'
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
import { Account } from '../interface/Account'
|
||||
import { Config } from '../interface/Config'
|
||||
import type { Account, ConfigSaveFingerprint } from '../interface/Account'
|
||||
import type { Config } from '../interface/Config'
|
||||
import { validateAccounts, validateConfig } from './Validator'
|
||||
|
||||
let configCache: Config
|
||||
|
||||
export function loadAccounts(): Account[] {
|
||||
try {
|
||||
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')
|
||||
const accountsData = JSON.parse(accounts)
|
||||
|
||||
return JSON.parse(accounts)
|
||||
validateAccounts(accountsData)
|
||||
|
||||
return accountsData
|
||||
} catch (error) {
|
||||
throw new Error(error as string)
|
||||
}
|
||||
@@ -25,11 +31,99 @@ export function loadAccounts(): Account[] {
|
||||
|
||||
export function loadConfig(): Config {
|
||||
try {
|
||||
if (configCache) {
|
||||
return configCache
|
||||
}
|
||||
|
||||
const configDir = path.join(__dirname, '../', 'config.json')
|
||||
const config = fs.readFileSync(configDir, 'utf-8')
|
||||
|
||||
return JSON.parse(config)
|
||||
const configData = JSON.parse(config)
|
||||
validateConfig(configData)
|
||||
|
||||
configCache = configData
|
||||
|
||||
return configData
|
||||
} catch (error) {
|
||||
throw new Error(error as string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadSessionData(
|
||||
sessionPath: string,
|
||||
email: string,
|
||||
saveFingerprint: ConfigSaveFingerprint,
|
||||
isMobile: boolean
|
||||
) {
|
||||
try {
|
||||
const cookiesFileName = isMobile ? 'session_mobile.json' : 'session_desktop.json'
|
||||
const cookieFile = path.join(__dirname, '../browser/', sessionPath, email, cookiesFileName)
|
||||
|
||||
let cookies: Cookie[] = []
|
||||
if (fs.existsSync(cookieFile)) {
|
||||
const cookiesData = await fs.promises.readFile(cookieFile, 'utf-8')
|
||||
cookies = JSON.parse(cookiesData)
|
||||
}
|
||||
|
||||
const fingerprintFileName = isMobile ? 'session_fingerprint_mobile.json' : 'session_fingerprint_desktop.json'
|
||||
const fingerprintFile = path.join(__dirname, '../browser/', sessionPath, email, fingerprintFileName)
|
||||
|
||||
let fingerprint!: BrowserFingerprintWithHeaders
|
||||
const shouldLoadFingerprint = isMobile ? saveFingerprint.mobile : saveFingerprint.desktop
|
||||
if (shouldLoadFingerprint && fs.existsSync(fingerprintFile)) {
|
||||
const fingerprintData = await fs.promises.readFile(fingerprintFile, '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,
|
||||
cookies: Cookie[],
|
||||
email: string,
|
||||
isMobile: boolean
|
||||
): Promise<string> {
|
||||
try {
|
||||
const sessionDir = path.join(__dirname, '../browser/', sessionPath, email)
|
||||
const cookiesFileName = isMobile ? 'session_mobile.json' : 'session_desktop.json'
|
||||
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
await fs.promises.mkdir(sessionDir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(path.join(sessionDir, cookiesFileName), JSON.stringify(cookies))
|
||||
|
||||
return sessionDir
|
||||
} catch (error) {
|
||||
throw new Error(error as string)
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveFingerprintData(
|
||||
sessionPath: string,
|
||||
email: string,
|
||||
isMobile: boolean,
|
||||
fingerpint: BrowserFingerprintWithHeaders
|
||||
): Promise<string> {
|
||||
try {
|
||||
const sessionDir = path.join(__dirname, '../browser/', sessionPath, email)
|
||||
const fingerprintFileName = isMobile ? 'session_fingerprint_mobile.json' : 'session_fingerprint_desktop.json'
|
||||
|
||||
if (!fs.existsSync(sessionDir)) {
|
||||
await fs.promises.mkdir(sessionDir, { recursive: true })
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(path.join(sessionDir, fingerprintFileName), JSON.stringify(fingerpint))
|
||||
|
||||
return sessionDir
|
||||
} catch (error) {
|
||||
throw new Error(error as string)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Webhook } from './Webhook'
|
||||
|
||||
export function log(title: string, message: string, type?: 'log' | 'warn' | 'error') {
|
||||
const currentTime = new Date().toISOString()
|
||||
|
||||
let str = ''
|
||||
|
||||
switch (type) {
|
||||
case 'warn':
|
||||
str = `[${currentTime}] [PID: ${process.pid}] [WARN] [${title}] ${message}`
|
||||
console.warn(str)
|
||||
break
|
||||
|
||||
case 'error':
|
||||
str = `[${currentTime}] [PID: ${process.pid}] [ERROR] [${title}] ${message}`
|
||||
console.error(str)
|
||||
break
|
||||
|
||||
default:
|
||||
str = `[${currentTime}] [PID: ${process.pid}] [LOG] [${title}] ${message}`
|
||||
console.log(str)
|
||||
break
|
||||
}
|
||||
|
||||
if (str) Webhook(str)
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { log } from './Logger'
|
||||
|
||||
import { ChromeVersion, EdgeVersion } from '../interface/UserAgentUtil'
|
||||
|
||||
export async function getUserAgent(mobile: boolean) {
|
||||
const system = getSystemComponents(mobile)
|
||||
const app = await getAppComponents(mobile)
|
||||
|
||||
const uaTemplate = mobile ?
|
||||
`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 uaMetadata = {
|
||||
mobile,
|
||||
platform: mobile ? 'Android' : 'Windows',
|
||||
fullVersionList: [
|
||||
{ brand: 'Not/A)Brand', version: '99.0.0.0' },
|
||||
{ brand: 'Microsoft Edge', version: app['edge_version'] },
|
||||
{ brand: 'Chromium', version: app['chrome_version'] }
|
||||
],
|
||||
brands: [
|
||||
{ brand: 'Not/A)Brand', version: '99' },
|
||||
{ brand: 'Microsoft Edge', version: app['edge_major_version'] },
|
||||
{ brand: 'Chromium', version: app['chrome_major_version'] }
|
||||
],
|
||||
platformVersion,
|
||||
architecture: mobile ? '' : 'x86',
|
||||
bitness: mobile ? '' : '64',
|
||||
model: ''
|
||||
}
|
||||
|
||||
return { userAgent: uaTemplate, userAgentMetadata: uaMetadata }
|
||||
}
|
||||
|
||||
export async function getChromeVersion(): Promise<string> {
|
||||
try {
|
||||
const request = {
|
||||
url: 'https://googlechromelabs.github.io/chrome-for-testing/last-known-good-versions.json',
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios(request)
|
||||
const data: ChromeVersion = response.data
|
||||
return data.channels.Stable.version
|
||||
|
||||
} catch (error) {
|
||||
throw log('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 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`
|
||||
}
|
||||
|
||||
return `${uaPlatform}; ${osId}`
|
||||
}
|
||||
|
||||
export async function getAppComponents(mobile: boolean) {
|
||||
const versions = await getEdgeVersions()
|
||||
const edgeVersion = mobile ? versions.android : versions.windows as string
|
||||
const edgeMajorVersion = edgeVersion?.split('.')[0]
|
||||
|
||||
const chromeVersion = await getChromeVersion()
|
||||
const chromeMajorVersion = chromeVersion?.split('.')[0]
|
||||
const chromeReducedVersion = `${chromeMajorVersion}.0.0.0`
|
||||
|
||||
return {
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
export default class Util {
|
||||
import ms, { StringValue } from 'ms'
|
||||
|
||||
async wait(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
export default class Util {
|
||||
async wait(time: number | string): Promise<void> {
|
||||
if (typeof time === 'string') {
|
||||
time = this.stringToNumber(time)
|
||||
}
|
||||
|
||||
return new Promise<void>(resolve => {
|
||||
setTimeout(resolve, time)
|
||||
})
|
||||
}
|
||||
|
||||
getFormattedDate(ms = Date.now()): string {
|
||||
const today = new Date(ms)
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0') // January is 0
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0') // January is 0
|
||||
const day = String(today.getDate()).padStart(2, '0')
|
||||
const year = today.getFullYear()
|
||||
|
||||
@@ -16,11 +21,19 @@ export default class Util {
|
||||
}
|
||||
|
||||
shuffleArray<T>(array: T[]): T[] {
|
||||
const shuffledArray = array.slice()
|
||||
for (let i = array.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
|
||||
shuffledArray.sort(() => Math.random() - 0.5)
|
||||
const a = array[i]
|
||||
const b = array[j]
|
||||
|
||||
return shuffledArray
|
||||
if (a === undefined || b === undefined) continue
|
||||
|
||||
array[i] = b
|
||||
array[j] = a
|
||||
}
|
||||
|
||||
return array
|
||||
}
|
||||
|
||||
randomNumber(min: number, max: number): number {
|
||||
@@ -39,4 +52,39 @@ export default class Util {
|
||||
return chunks
|
||||
}
|
||||
|
||||
}
|
||||
stringToNumber(input: string | number): number {
|
||||
if (typeof input === 'number') {
|
||||
return input
|
||||
}
|
||||
const value = input.trim()
|
||||
|
||||
const milisec = ms(value as StringValue)
|
||||
|
||||
if (milisec === undefined) {
|
||||
throw new Error(
|
||||
`The input provided (${input}) cannot be parsed to a valid time! Use a format like "1 min", "1m" or "1 minutes"`
|
||||
)
|
||||
}
|
||||
|
||||
return milisec
|
||||
}
|
||||
|
||||
normalizeString(string: string): string {
|
||||
return string
|
||||
.normalize('NFD')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^\x20-\x7E]/g, '')
|
||||
.replace(/[?!]/g, '')
|
||||
}
|
||||
|
||||
getEmailUsername(email: string): string {
|
||||
return email.split('@')[0] ?? 'Unknown'
|
||||
}
|
||||
|
||||
randomDelay(min: string | number, max: string | number): number {
|
||||
const minMs = typeof min === 'number' ? min : this.stringToNumber(min)
|
||||
const maxMs = typeof max === 'number' ? max : this.stringToNumber(max)
|
||||
return Math.floor(this.randomNumber(minMs, maxMs))
|
||||
}
|
||||
}
|
||||
|
||||
131
src/util/Validator.ts
Normal file
131
src/util/Validator.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { z } from 'zod'
|
||||
import semver from 'semver'
|
||||
import pkg from '../../package.json'
|
||||
|
||||
import { Config } from '../interface/Config'
|
||||
import { Account } from '../interface/Account'
|
||||
|
||||
const NumberOrString = z.union([z.number(), z.string()])
|
||||
|
||||
const LogFilterSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
mode: z.enum(['whitelist', 'blacklist']),
|
||||
levels: z.array(z.enum(['debug', 'info', 'warn', 'error'])).optional(),
|
||||
keywords: z.array(z.string()).optional(),
|
||||
regexPatterns: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
const DelaySchema = z.object({
|
||||
min: NumberOrString,
|
||||
max: NumberOrString
|
||||
})
|
||||
|
||||
const QueryEngineSchema = z.enum(['google', 'wikipedia', 'reddit', 'local'])
|
||||
|
||||
// Webhook
|
||||
const WebhookSchema = z.object({
|
||||
discord: z
|
||||
.object({
|
||||
enabled: z.boolean(),
|
||||
url: z.string()
|
||||
})
|
||||
.optional(),
|
||||
ntfy: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
url: z.string(),
|
||||
topic: z.string().optional(),
|
||||
token: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
priority: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]).optional()
|
||||
})
|
||||
.optional(),
|
||||
webhookLogFilter: LogFilterSchema
|
||||
})
|
||||
|
||||
// Config
|
||||
export const ConfigSchema = z.object({
|
||||
baseURL: z.string(),
|
||||
sessionPath: z.string(),
|
||||
headless: z.boolean(),
|
||||
runOnZeroPoints: z.boolean(),
|
||||
clusters: z.number().int().nonnegative(),
|
||||
errorDiagnostics: z.boolean(),
|
||||
workers: z.object({
|
||||
doDailySet: z.boolean(),
|
||||
doSpecialPromotions: z.boolean(),
|
||||
doMorePromotions: z.boolean(),
|
||||
doPunchCards: z.boolean(),
|
||||
doAppPromotions: z.boolean(),
|
||||
doDesktopSearch: z.boolean(),
|
||||
doMobileSearch: z.boolean(),
|
||||
doDailyCheckIn: z.boolean(),
|
||||
doReadToEarn: z.boolean()
|
||||
}),
|
||||
searchOnBingLocalQueries: z.boolean(),
|
||||
globalTimeout: NumberOrString,
|
||||
searchSettings: z.object({
|
||||
scrollRandomResults: z.boolean(),
|
||||
clickRandomResults: z.boolean(),
|
||||
parallelSearching: z.boolean(),
|
||||
queryEngines: z.array(QueryEngineSchema),
|
||||
searchResultVisitTime: NumberOrString,
|
||||
searchDelay: DelaySchema,
|
||||
readDelay: DelaySchema
|
||||
}),
|
||||
debugLogs: z.boolean(),
|
||||
proxy: z.object({
|
||||
queryEngine: z.boolean()
|
||||
}),
|
||||
consoleLogFilter: LogFilterSchema,
|
||||
webhook: WebhookSchema
|
||||
})
|
||||
|
||||
// Account
|
||||
export const AccountSchema = z.object({
|
||||
email: z.string(),
|
||||
password: z.string(),
|
||||
totpSecret: z.string().optional(),
|
||||
recoveryEmail: z.string(),
|
||||
geoLocale: z.string(),
|
||||
langCode: z.string(),
|
||||
proxy: z.object({
|
||||
proxyAxios: z.boolean(),
|
||||
url: z.string(),
|
||||
port: z.number(),
|
||||
password: z.string(),
|
||||
username: z.string()
|
||||
}),
|
||||
saveFingerprint: z.object({
|
||||
mobile: z.boolean(),
|
||||
desktop: z.boolean()
|
||||
})
|
||||
})
|
||||
|
||||
export function validateConfig(data: unknown): Config {
|
||||
return ConfigSchema.parse(data) as Config
|
||||
}
|
||||
|
||||
export function validateAccounts(data: unknown): Account[] {
|
||||
return z.array(AccountSchema).parse(data)
|
||||
}
|
||||
|
||||
export function checkNodeVersion(): void {
|
||||
try {
|
||||
const requiredVersion = pkg.engines?.node
|
||||
|
||||
if (!requiredVersion) {
|
||||
console.warn('No Node.js version requirement found in package.json "engines" field.')
|
||||
return
|
||||
}
|
||||
|
||||
if (!semver.satisfies(process.version, requiredVersion)) {
|
||||
console.error(`Current Node.js version ${process.version} does not satisfy requirement: ${requiredVersion}`)
|
||||
process.exit(1)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to validate Node.js version:', error)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
@@ -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(() => { })
|
||||
}
|
||||
@@ -2,18 +2,18 @@
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||
/* Basic Options */
|
||||
"target": "ES2020", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */
|
||||
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
|
||||
"target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
|
||||
"module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */,
|
||||
// "lib": [], /* Specify library files to be included in the compilation. */
|
||||
// "allowJs": true, /* Allow javascript files to be compiled. */
|
||||
// "checkJs": true, /* Report errors in .js files. */
|
||||
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
|
||||
"declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||
"declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||
"sourceMap": true, /* Generates corresponding '.map' file. */
|
||||
"declaration": true /* Generates corresponding '.d.ts' file. */,
|
||||
"declarationMap": true /* Generates a sourcemap for each corresponding '.d.ts' file. */,
|
||||
"sourceMap": true /* Generates corresponding '.map' file. */,
|
||||
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||
"outDir": "./dist", /* Redirect output structure to the directory. */
|
||||
"rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||
"outDir": "./dist" /* Redirect output structure to the directory. */,
|
||||
"rootDir": "./src" /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */,
|
||||
// "composite": true, /* Enable project compilation */
|
||||
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||
// "removeComments": true, /* Do not emit comments to output. */
|
||||
@@ -22,31 +22,38 @@
|
||||
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||
/* Strict Type-Checking Options */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||
"strictNullChecks": true, /* Enable strict null checks. */
|
||||
"strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
"noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */,
|
||||
"strictNullChecks": true /* Enable strict null checks. */,
|
||||
"strictFunctionTypes": true /* Enable strict checking of function types. */,
|
||||
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||
"alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||
"noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
|
||||
"alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */,
|
||||
/* Additional Checks */
|
||||
"noUnusedLocals": true, /* Report errors on unused locals. */
|
||||
"noUnusedLocals": true /* Report errors on unused locals. */,
|
||||
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||
"noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||
"noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||
"noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
|
||||
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an 'override' modifier. */
|
||||
"noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
|
||||
"noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
|
||||
"noUncheckedIndexedAccess": true /* Include 'undefined' in index signature results */,
|
||||
"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"
|
||||
],
|
||||
"typeRoots": [
|
||||
"./node_modules/@types"
|
||||
],
|
||||
// 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. */
|
||||
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||
// "types": [], /* Type declaration files to be included in compilation. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||
"esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */,
|
||||
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
/* Source Map Options */
|
||||
@@ -58,14 +65,16 @@
|
||||
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||
/* Advanced Options */
|
||||
"skipLibCheck": true, /* Skip type checking of declaration files. */
|
||||
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
|
||||
"skipLibCheck": true /* Skip type checking of declaration files. */,
|
||||
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/accounts.json",
|
||||
"src/config.json"
|
||||
"src/config.json",
|
||||
"src/functions/bing-search-activity-queries.json",
|
||||
"src/functions/search-queries.json"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
|
||||
Reference in New Issue
Block a user