#!/usr/bin/env node
/**
* @license
* Copyright (c) 2024 weread-challenge@techfetch.dev
* All rights reserved.
* Licensed under the MIT License.
* For more information, contact: weread-challenge@techfetch.dev
* 修改请保留统计代码
*/
const { By, Builder, Browser, until, Key } = require("selenium-webdriver");
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const https = require("https");
const http = require("http");
const { execSync, spawnSync } = require("child_process");
const os = require("os");
function getWereadVersion() {
const packageJsonPaths = [
path.resolve(__dirname, "../package.json"),
path.resolve(__dirname, "./package.json"),
];
for (const packageJsonPath of packageJsonPaths) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
if (pkg && typeof pkg.version === "string" && pkg.version.trim() !== "") {
return pkg.version.trim();
}
} catch (_) {}
}
return process.env.WEREAD_VERSION || "0.0.0";
}
const WEREAD_VERSION = getWereadVersion();
const WEREAD_URL = "https://weread.qq.com/"; // Replace with the target URL
const QR_EXPIRED_TEXTS = ["点击刷新二维码", "二维码已失效"]; // 登录二维码过期提示
let lastPushedLoginLink = "";
let logStream = null;
let DEBUG = false; // Enable debug mode
let WEREAD_USER = "weread-default"; // User to use
let WEREAD_REMOTE_BROWSER = "";
let WEREAD_DURATION = 10; // Reading duration in minutes
let WEREAD_SPEED = "slow"; // Reading speed, slow | normal | fast
let WEREAD_SELECTION = 2; // Selection method
let WEREAD_BROWSER = Browser.CHROME; // Browser to use, chrome | MicrosoftEdge | firefox
let ENABLE_EMAIL = false; // Enable email notifications
let WEREAD_SCREENSHOT = true; // Reading期间是否每分钟截图
let WEREAD_AGREE_TERMS = true; // Agree to terms
let EMAIL_PORT = 465; // SMTP port number, default 465
let BARK_KEY = ""; // Bark推送密钥
let BARK_SERVER = "https://api.day.app"; // Bark服务器地址
let WEREAD_DATA_DIR = ".weread"; // 默认数据目录
let DEFAULT_BOOK_URL =
"https://weread.qq.com/web/reader/276323e0813ab90a5g0144d7"; // 默认阅读链接
// env vars:
// WEREAD_REMOTE_BROWSER
// WEREAD_DURATION
// WEREAD_BROWSER
// ENABLE_EMAIL
// WEREAD_SCREENSHOT
// EMAIL_SMTP
// EMAIL_USER
// EMAIL_PASS
// EMAIL_FROM
// EMAIL_TO
// BARK_KEY
// BARK_SERVER
// WEREAD_DATA_DIR
// DEFAULT_BOOK_URL
const RUN_OPTION_SPECS = [
{ envKey: "DEBUG", flag: "debug", type: "boolean", description: "Enable debug logging." },
{ envKey: "WEREAD_USER", flag: "weread-user", type: "string", description: "Browser profile directory name." },
{ envKey: "WEREAD_REMOTE_BROWSER", flag: "weread-remote-browser", type: "string", description: "Remote Selenium URL." },
{ envKey: "WEREAD_DURATION", flag: "weread-duration", type: "integer", description: "Reading duration in minutes." },
{ envKey: "WEREAD_SPEED", flag: "weread-speed", type: "string", description: "Reading speed: slow | normal | fast." },
{ envKey: "WEREAD_SELECTION", flag: "weread-selection", type: "integer", description: "Book selection index." },
{ envKey: "WEREAD_BROWSER", flag: "weread-browser", type: "string", description: "Browser name: chrome | MicrosoftEdge | firefox | safari." },
{ envKey: "ENABLE_EMAIL", flag: "enable-email", type: "boolean", description: "Enable email notifications." },
{ envKey: "WEREAD_SCREENSHOT", flag: "weread-screenshot", type: "boolean", description: "Capture screenshots while reading." },
{ envKey: "WEREAD_AGREE_TERMS", flag: "weread-agree-terms", type: "boolean", description: "Enable usage telemetry upload." },
{ envKey: "EMAIL_SMTP", flag: "email-smtp", type: "string", description: "SMTP server host." },
{ envKey: "EMAIL_USER", flag: "email-user", type: "string", description: "SMTP username." },
{ envKey: "EMAIL_PASS", flag: "email-pass", type: "string", description: "SMTP password." },
{ envKey: "EMAIL_FROM", flag: "email-from", type: "string", description: "Email from address." },
{ envKey: "EMAIL_TO", flag: "email-to", type: "string", description: "Email recipient." },
{ envKey: "EMAIL_PORT", flag: "email-port", type: "integer", description: "SMTP port." },
{ envKey: "BARK_KEY", flag: "bark-key", type: "string", description: "Bark notification key." },
{ envKey: "BARK_SERVER", flag: "bark-server", type: "string", description: "Bark server base URL." },
{ envKey: "WEREAD_DATA_DIR", flag: "weread-data-dir", type: "string", description: "Data directory for cookies, logs and screenshots." },
{ envKey: "DEFAULT_BOOK_URL", flag: "default-book-url", type: "string", description: "Fallback reading URL." },
];
function parseBooleanValue(value, defaultValue = false, strict = false) {
if (value === undefined || value === null || value === "") {
return defaultValue;
}
if (typeof value === "boolean") {
return value;
}
const normalized = String(value).trim().toLowerCase();
if (["true", "1", "yes", "y", "on"].includes(normalized)) {
return true;
}
if (["false", "0", "no", "n", "off"].includes(normalized)) {
return false;
}
if (!strict) {
return defaultValue;
}
throw new Error(`Invalid boolean value: ${value}`);
}
function parseIntegerValue(value, flagName) {
const parsed = Number.parseInt(String(value), 10);
if (!Number.isInteger(parsed)) {
throw new Error(`--${flagName} must be an integer`);
}
return parsed;
}
function resolveDefaultDataDir(cwd = process.cwd()) {
const preferredDir = ".weread";
const legacyDir = "data";
const preferredPath = path.resolve(cwd, preferredDir);
if (fs.existsSync(preferredPath)) {
return preferredDir;
}
const legacyPath = path.resolve(cwd, legacyDir);
if (fs.existsSync(legacyPath)) {
return legacyDir;
}
return preferredDir;
}
function setRuntimeConfigFromEnv(env = process.env) {
DEBUG = parseBooleanValue(env.DEBUG, false, false);
WEREAD_USER = env.WEREAD_USER || "weread-default";
WEREAD_REMOTE_BROWSER = env.WEREAD_REMOTE_BROWSER || "";
WEREAD_DURATION = env.WEREAD_DURATION === undefined
? 10
: parseIntegerValue(env.WEREAD_DURATION, "weread-duration");
WEREAD_SPEED = env.WEREAD_SPEED || "slow";
WEREAD_SELECTION = env.WEREAD_SELECTION === undefined
? 2
: parseIntegerValue(env.WEREAD_SELECTION, "weread-selection");
WEREAD_BROWSER = env.WEREAD_BROWSER || Browser.CHROME;
ENABLE_EMAIL = parseBooleanValue(env.ENABLE_EMAIL, false, false);
WEREAD_SCREENSHOT = env.WEREAD_SCREENSHOT === undefined
? true
: parseBooleanValue(env.WEREAD_SCREENSHOT, true, false);
WEREAD_AGREE_TERMS = env.WEREAD_AGREE_TERMS === undefined
? true
: parseBooleanValue(env.WEREAD_AGREE_TERMS, true, false);
EMAIL_PORT = env.EMAIL_PORT === undefined
? 465
: parseIntegerValue(env.EMAIL_PORT, "email-port");
BARK_KEY = env.BARK_KEY || "";
BARK_SERVER = env.BARK_SERVER || "https://api.day.app";
WEREAD_DATA_DIR = env.WEREAD_DATA_DIR || resolveDefaultDataDir();
DEFAULT_BOOK_URL =
env.DEFAULT_BOOK_URL ||
"https://weread.qq.com/web/reader/276323e0813ab90a5g0144d7";
}
function getRunFlagValue(flags, spec) {
if (Object.prototype.hasOwnProperty.call(flags, spec.flag)) {
return flags[spec.flag];
}
if (Object.prototype.hasOwnProperty.call(flags, spec.envKey)) {
return flags[spec.envKey];
}
return undefined;
}
function applyRunCliOverrides(flags = {}) {
for (const spec of RUN_OPTION_SPECS) {
const rawValue = getRunFlagValue(flags, spec);
if (rawValue === undefined) {
continue;
}
if (spec.type === "boolean") {
process.env[spec.envKey] = parseBooleanValue(rawValue, true, true) ? "true" : "false";
continue;
}
if (spec.type === "integer") {
process.env[spec.envKey] = String(parseIntegerValue(rawValue, spec.flag));
continue;
}
process.env[spec.envKey] = String(rawValue);
}
setRuntimeConfigFromEnv(process.env);
}
function getRunOptionsHelpLines() {
return RUN_OPTION_SPECS.map(
(spec) =>
` --${spec.flag} ${spec.description} Env: ${spec.envKey}`
).join("\n");
}
setRuntimeConfigFromEnv(process.env);
function getDataDirPath() {
return path.resolve(WEREAD_DATA_DIR);
}
function getCookieFilePath() {
return path.join(getDataDirPath(), "cookies.json");
}
function getLoginQrCodePath() {
return path.join(getDataDirPath(), "login.png");
}
function getOutputLogPath() {
return path.join(getDataDirPath(), "output.log");
}
function getScreenshotPath(fileName = "screenshot.png") {
return path.join(getDataDirPath(), fileName);
}
function ensureDataDir() {
const dataDir = getDataDirPath();
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
}
function initializeRuntime() {
if (logStream) {
return;
}
ensureDataDir();
logStream = fs.createWriteStream(getOutputLogPath(), { flags: "w" });
if (!DEBUG) {
["info", "warn", "error"].forEach(redirectConsole);
}
}
function formatLocalTimestamp(d = new Date()) {
const pad2 = (n) => String(n).padStart(2, "0");
const pad3 = (n) => String(n).padStart(3, "0");
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(
d.getDate()
)} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(
d.getSeconds()
)}.${pad3(d.getMilliseconds())}`;
}
function escapeHtml(str = "") {
return String(str)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function formatEmailSubject(subject) {
const versionLabel = `[v${WEREAD_VERSION}]`;
return String(subject).includes(versionLabel)
? String(subject)
: `${subject} ${versionLabel}`;
}
function resolveEmailAttachments(filePaths = []) {
return filePaths
.filter(Boolean)
.filter((filePath) => {
if (fs.existsSync(filePath)) {
return true;
}
console.warn("邮件附件不存在,已跳过:", filePath);
return false;
});
}
function buildReportEmailHtml(text, attachments = [], options = {}) {
const safeText = escapeHtml(text);
const versionText = escapeHtml(WEREAD_VERSION);
const reportDate = escapeHtml(new Date().toLocaleDateString());
const extraHtml = options.extraHtml || "";
const imageGallery = attachments.length
? `
${attachments
.map(
(att) => `

`
)
.join("")}
`
: "";
return `
WeRead Challenge Daily Report
${reportDate}
Version ${versionText}
Dear User,
${safeText}
Here are your reading statistics and achievements for today.
${extraHtml}
${imageGallery}
Best regards,
WeRead Challenge Team
This is an automated message, please do not reply.
Version ${versionText}
`;
}
// Utility function to redirect logging
function redirectConsole(method) {
const originalMethod = console[method];
console[method] = function (...args) {
let logstr = `[${method.toUpperCase()}][${formatLocalTimestamp()}]: ` + args.join(" ");
// Write to the log file
if (logStream) {
logStream.write(logstr + "\r\n");
}
// Also log to the console
console.log(logstr);
};
}
// --- 诊断与健康检查工具函数 ---
function isHttpUrl(str) {
try {
const u = new URL(str);
return u.protocol === "http:" || u.protocol === "https:";
} catch (_) {
return false;
}
}
async function fetchJson(url, timeoutMs = 3000) {
return await new Promise((resolve, reject) => {
const client = url.startsWith("https:") ? https : http;
const req = client.get(url, { timeout: timeoutMs }, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
try {
resolve({ statusCode: res.statusCode, body: JSON.parse(data || "{}") });
} catch (e) {
resolve({ statusCode: res.statusCode, body: data });
}
});
});
req.on("timeout", () => {
req.destroy(new Error("request timeout"));
});
req.on("error", reject);
});
}
async function checkSeleniumHealth(remoteUrl) {
try {
if (!remoteUrl || !isHttpUrl(remoteUrl)) {
console.warn("跳过健康检查:WEREAD_REMOTE_BROWSER 未设置或非法。");
return null;
}
// 优先 /status,兼容 /wd/hub/status
const base = remoteUrl.endsWith("/") ? remoteUrl.slice(0, -1) : remoteUrl;
const endpoints = ["/status", "/wd/hub/status"];
for (const ep of endpoints) {
try {
const { statusCode, body } = await fetchJson(`${base}${ep}`, 3000);
if (statusCode >= 200 && statusCode < 300) {
const ready = body?.ready ?? body?.value?.ready;
console.info(`Selenium 健康检查 ${ep} 响应: ready=${ready}`);
return { endpoint: ep, ready, raw: body };
}
} catch (_) {
// 继续尝试下一个端点
}
}
console.warn("Selenium 健康检查失败:所有端点无有效响应。");
return null;
} catch (e) {
console.warn("Selenium 健康检查异常:", e.message || e);
return null;
}
}
function dockerAvailable() {
try {
const out = spawnSync("docker", ["version"], { encoding: "utf8" });
return out.status === 0;
} catch (_) {
return false;
}
}
function findSeleniumContainers() {
try {
const out = execSync(
'docker ps --format "{{.ID}}\t{{.Image}}\t{{.Names}}"',
{ encoding: "utf8" }
);
const lines = out.split(/\r?\n/).filter(Boolean);
const hits = lines
.map((l) => {
const [id, image, name] = l.split(/\t/);
return { id, image, name };
})
.filter((x) =>
/selenium\/(standalone-|node-).*chrome/i.test(x.image || "") ||
/selenium/i.test(x.name || "")
);
return hits;
} catch (e) {
console.warn("查找 Selenium 容器失败:", e.message || e);
return [];
}
}
function collectSeleniumLogs(tail = 300) {
try {
if (!dockerAvailable()) {
console.warn("Docker 不可用,跳过 selenium 日志抓取。");
return null;
}
const containers = findSeleniumContainers();
if (!containers.length) {
console.warn("未发现运行中的 selenium 容器,跳过日志抓取。");
return null;
}
const ts = new Date()
.toISOString()
.replace(/[:.]/g, "-")
.replace("T", "_")
.replace("Z", "");
const outFile = path.join(getDataDirPath(), `selenium-logs-${ts}.log`);
let combined = "";
for (const c of containers) {
try {
const logs = execSync(`docker logs --tail=${tail} ${c.id} 2>&1`, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
combined += `\n===== CONTAINER ${c.name} (${c.image}) =====\n` + logs;
} catch (e) {
combined += `\n===== CONTAINER ${c.name} (${c.image}) 日志获取失败: ${e.message} =====\n`;
}
}
fs.writeFileSync(outFile, combined, "utf8");
console.info("已抓取 selenium 容器日志:", outFile);
return outFile;
} catch (e) {
console.warn("保存 selenium 日志失败:", e.message || e);
return null;
}
}
async function collectDiagnostics(reason) {
try {
console.warn("开始收集诊断信息,原因:", reason?.toString()?.slice(0, 180) || "未知");
await checkSeleniumHealth(WEREAD_REMOTE_BROWSER);
collectSeleniumLogs(400);
} catch (_) {
// 忽略诊断过程错误
}
}
function getOSInfo() {
const platform = os.platform();
const release = os.release();
switch (platform) {
case "win32":
return `Windows ${release}`;
case "darwin":
return `MacOS ${release}`;
case "linux":
return `Linux ${release}`;
default:
return `${platform} ${release}`;
}
}
// post data to weread log
function logEventToWereadLog(err) {
const url = DEBUG
? "http://127.0.0.1:8787/logs"
: "https://weread-challenge.techfetch.dev/logs";
const httpModule = DEBUG ? http : https;
let userInfo = getUserInfo();
let params = {
os: getOSInfo(),
browser: WEREAD_BROWSER,
duration: parseInt(WEREAD_DURATION) || 0,
enable_email: ENABLE_EMAIL,
error: err,
version: WEREAD_VERSION,
};
let data = { ...params, ...userInfo };
const options = {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": "WeRead-Tracker/1.0",
},
};
// log stringified data
console.info("Logging to WeRead server:", JSON.stringify(data));
const req = httpModule.request(url, options, (res) => {
let responseData = "";
res.on("data", (chunk) => {
responseData += chunk;
});
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.info("Successfully logged to WeRead server");
} else {
console.error(
`Failed to log to WeRead server: ${res.statusCode} - ${responseData}`
);
}
});
});
req.on("error", (error) => {
console.error("Error logging to WeRead server:", error.message);
});
req.write(JSON.stringify(data));
req.end();
}
function getUserInfo() {
// return empty object if cookies file not found
if (!fs.existsSync(getCookieFilePath())) {
return {};
}
// read from cookies
let cookiesFile = fs.readFileSync(getCookieFilePath(), "utf8");
let cookies = JSON.parse(cookiesFile);
let userInfo = {};
for (const cookie of cookies) {
if (cookie.secure == undefined) {
continue;
}
switch (cookie.name) {
case "wr_gid":
if (cookie.secure == true) {
userInfo.wr_gid_s = parseInt(cookie.value) || 0;
} else {
userInfo.wr_gid = parseInt(cookie.value) || 0;
}
break;
case "wr_name":
userInfo.wr_name = decodeURIComponent(cookie.value);
break;
case "wr_localvid":
userInfo.wr_localvid = cookie.value;
break;
case "wr_gender":
userInfo.wr_gender = parseInt(cookie.value) || 0;
break;
case "wr_avatar":
userInfo.wr_avatar = decodeURIComponent(cookie.value);
break;
case "wr_rt":
userInfo.wr_rt = cookie.value;
break;
case "wr_vid":
userInfo.wr_vid = parseInt(cookie.value) || 0;
break;
}
}
return userInfo;
}
async function saveCookies(driver, filePath) {
let cookies = await driver.manage().getCookies();
// If using Safari, set secure to true for all cookies
if (WEREAD_BROWSER === Browser.SAFARI) {
cookies = cookies.map(cookie => ({ ...cookie, secure: true }));
}
fs.writeFileSync(filePath, JSON.stringify(cookies, null, 2));
console.info("Cookies saved successfully.");
}
async function loadCookies(driver, filePath) {
if (!fs.existsSync(filePath)) {
console.warn("No cookies file found.");
return;
}
const cookies = JSON.parse(fs.readFileSync(filePath, "utf8"));
for (const cookie of cookies) {
await driver.manage().addCookie(cookie);
}
console.info("Cookies loaded successfully.");
}
async function pressDownArrow(driver) {
await driver.actions().sendKeys(Key.ARROW_DOWN).perform();
// keep the key pressed for random time between 50ms to 500ms
let randomTime = Math.floor(Math.random() * 450) + 50;
await new Promise((resolve) => setTimeout(resolve, randomTime));
// release the down arrow key
await driver.actions().sendKeys(Key.NULL).perform();
}
// Function to check if element is in viewport
async function isElementInViewport(driver, element) {
// Get viewport dimensions using JavaScript
const viewport = await driver.executeScript(`
return {
height: window.innerHeight,
width: window.innerWidth
};
`);
// Get element position and size
const rect = await driver.executeScript(
`
const rect = arguments[0].getBoundingClientRect();
return {
top: rect.top,
left: rect.left,
bottom: rect.bottom,
right: rect.right
};
`,
element
);
// Check if element is within viewport
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= viewport.height &&
rect.right <= viewport.width &&
(await element.isDisplayed())
);
}
// 简化的二维码定位函数
async function findQRCodeElement(driver) {
try {
console.info("正在查找二维码登录元素...");
// 使用更精确的定位策略,优先查找二维码图片
const qrCodeImg = await driver.wait(
until.elementLocated(
By.xpath("//img[contains(@class, 'qr') or contains(@src, 'qr') or contains(@alt, '二维码')]")
),
3000
);
console.info("找到二维码图片元素");
return true;
} catch (e) {
try {
// 备选方案:查找包含"扫码"或"二维码"文本的元素
await driver.wait(
until.elementLocated(
By.xpath("//*[contains(text(), '扫码') or contains(text(), '二维码')]")
),
3000
);
console.info("找到包含'扫码'或'二维码'文本的元素");
return true;
} catch (e) {
console.info("未找到二维码相关元素,可能已经登录");
return false;
}
}
}
// 从页面二维码图片中解码登录链接并在终端显示为二维码
async function extractAndDisplayQRCode(driver) {
try {
const qrImg = await driver.findElement(
By.xpath("//img[contains(@class, 'qr') or contains(@src, 'qr') or contains(@alt, '二维码')]")
);
const base64Png = await qrImg.takeScreenshot();
const { PNG } = require('pngjs');
const png = PNG.sync.read(Buffer.from(base64Png, 'base64'));
const jsQR = require('jsqr');
const code = jsQR(new Uint8ClampedArray(png.data), png.width, png.height);
if (code && code.data) {
console.info("登录链接:", code.data);
const qrcode = require('qrcode-terminal');
qrcode.generate(code.data, { small: true });
await notifyLoginLink(code.data);
return code.data;
}
console.warn("无法从二维码图片中解析登录链接");
return null;
} catch (e) {
console.warn("提取二维码登录链接失败:", e.message);
return null;
}
}
function canSendLoginLinkEmail() {
if (!ENABLE_EMAIL) {
return false;
}
const required = ["EMAIL_SMTP", "EMAIL_USER", "EMAIL_PASS", "EMAIL_TO"];
const missed = required.filter((key) => !process.env[key]);
if (missed.length) {
console.warn("邮件推送登录链接失败:缺少配置", missed.join(", "));
return false;
}
return true;
}
function buildLoginLinkEmailHtml(loginUrl) {
const safeUrl = escapeHtml(loginUrl);
const qrImageUrl = `https://api.qrserver.com/v1/create-qr-code/?size=360x360&data=${encodeURIComponent(loginUrl)}`;
const safeQrImageUrl = escapeHtml(qrImageUrl);
const versionText = escapeHtml(WEREAD_VERSION);
return `
微信读书登录二维码
Version ${versionText}
检测到新的扫码登录链接,请尽快在手机端完成登录。
该链接由 weread-challenge 自动发送。Version ${versionText}
`;
}
async function notifyLoginLink(loginUrl) {
if (!loginUrl) {
return;
}
if (loginUrl === lastPushedLoginLink) {
console.info("登录链接未变化,跳过重复推送");
return;
}
lastPushedLoginLink = loginUrl;
const tasks = [];
if (BARK_KEY) {
const qrImageUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(loginUrl)}`;
tasks.push(
sendBark("微信读书挑战", "请扫码登录微信读书", {
subtitle: "扫码登录",
url: loginUrl,
image: qrImageUrl,
level: "active",
sound: "birdsong",
})
);
}
if (canSendLoginLinkEmail()) {
tasks.push(
sendMail(
"[项目进展--登录链接]",
"检测到新的微信读书登录二维码,请在邮件中扫码登录。",
[],
{ html: buildLoginLinkEmailHtml(loginUrl) }
)
);
}
if (!tasks.length) {
console.info("未启用登录链接推送(需要 BARK_KEY 或 ENABLE_EMAIL=true)");
return;
}
await Promise.allSettled(tasks);
}
// 安全点击元素函数,处理元素被拦截的情况
async function safeClickElement(driver, element, description = "元素") {
try {
// 首先检查元素是否可见和可点击
const isDisplayed = await element.isDisplayed();
if (!isDisplayed) {
console.warn(`${description}不可见,尝试滚动到元素位置`);
await driver.executeScript("arguments[0].scrollIntoView({block: 'center'});", element);
await new Promise((resolve) => setTimeout(resolve, 500));
}
// 尝试直接点击
await element.click();
console.info(`成功点击${description}`);
return true;
} catch (error) {
console.warn(`直接点击${description}失败: ${error.message}`);
try {
// 尝试使用JavaScript点击
console.info(`尝试使用JavaScript点击${description}`);
await driver.executeScript("arguments[0].click();", element);
console.info(`使用JavaScript成功点击${description}`);
return true;
} catch (jsError) {
console.warn(`使用JavaScript点击${description}失败: ${jsError.message}`);
try {
// 尝试使用Actions类模拟点击
console.info(`尝试使用Actions类点击${description}`);
const actions = driver.actions({ bridge: true });
await actions.move({ origin: element }).click().perform();
console.info(`使用Actions类成功点击${description}`);
return true;
} catch (actionError) {
console.error(`所有点击方法都失败: ${actionError.message}`);
return false;
}
}
}
}
// 刷新二维码的函数
async function refreshQRCode(driver) {
try {
console.info("开始刷新二维码...");
// 尝试多种方式找到刷新按钮
const refreshLocators = [
By.css(".login_dialog_retry_delegate"),
By.xpath("//div[contains(@class, 'login_dialog_retry_delegate')]"),
By.xpath("//div[contains(text(), '点击刷新二维码') and @class='wr_login_modal_qr_overlay_text']"),
By.xpath("//div[contains(text(), '点击刷新二维码')]"),
By.xpath("//div[@class='login_dialog_retry_delegate']"),
By.xpath("//div[contains(@class, 'refresh') or contains(@class, 'retry')]"),
By.xpath("//button[contains(text(), '刷新')]"),
By.xpath("//span[contains(text(), '刷新')]")
];
let refreshClicked = false;
let refreshElement = null;
// 尝试每个定位器
for (const locator of refreshLocators) {
try {
refreshElement = await driver.wait(until.elementLocated(locator), 2000);
if (refreshElement) {
console.info(`找到刷新元素,尝试点击: ${locator.toString()}`);
refreshClicked = await safeClickElement(driver, refreshElement, "刷新按钮");
if (refreshClicked) {
try {
await driver.wait(until.stalenessOf(refreshElement), 3000);
} catch (waitError) {
console.debug(`刷新元素可能未及时从DOM移除: ${waitError.message}`);
}
break;
}
}
} catch (e) {
console.debug(`未找到元素: ${locator.toString()}`);
}
}
if (!refreshClicked) {
console.warn("常规定位失败,尝试执行脚本触发刷新");
try {
const jsClicked = await driver.executeScript(
"const delegate = document.querySelector('.login_dialog_retry_delegate'); if (delegate) { delegate.click(); return true; } return false;"
);
if (!jsClicked) {
console.error("无法找到或点击任何刷新按钮");
return false;
}
refreshClicked = true;
} catch (scriptError) {
console.error(`执行脚本触发刷新失败: ${scriptError.message}`);
return false;
}
}
// 等待页面加载
await new Promise((resolve) => setTimeout(resolve, 2000));
// 检查二维码是否已刷新
let qrElementFound = await findQRCodeElement(driver);
if (qrElementFound) {
// 避免截图时二维码还未弹出
await new Promise((resolve) => setTimeout(resolve, 1000));
// 保存截图
await driver.takeScreenshot().then((image, err) => {
fs.writeFileSync(getLoginQrCodePath(), image, "base64");
});
console.info("QR code refreshed, datetime: ", new Date());
await extractAndDisplayQRCode(driver);
return true;
} else {
console.error("刷新后未能找到任何二维码相关元素");
return false;
}
} catch (error) {
console.error("刷新二维码过程中发生错误:", error.message);
return false;
}
}
async function sendMail(subject, text, filePaths = [], options = {}) {
const nodemailer = require("nodemailer");
// 根据端口自动判断是否使用SSL
// 通常 465 使用 SSL,587 和 25 不使用
const secure = EMAIL_PORT === 465;
// Create transporter object using SMTP transport
let transporter = nodemailer.createTransport({
host: process.env.EMAIL_SMTP,
port: EMAIL_PORT,
secure: secure, // true for 465, false for other ports
auth: {
user: process.env.EMAIL_USER,
pass: process.env.EMAIL_PASS,
},
});
const existingFilePaths = resolveEmailAttachments(filePaths);
// Convert image paths to attachments array
const attachments = existingFilePaths.map((filePath) => ({
filename: path.basename(filePath),
path: filePath,
cid: path.basename(filePath), // Content ID for embedding in HTML
contentType: `image/${path.extname(filePath).substring(1)}`, // Automatically detect image type
}));
// Use EMAIL_FROM if provided, otherwise fall back to EMAIL_USER
const fromAddress = process.env.EMAIL_FROM || process.env.EMAIL_USER;
const defaultHtml = buildReportEmailHtml(text, attachments, options);
// Email options with updated from field
let mailOptions = {
from: fromAddress,
to: process.env.EMAIL_TO,
subject: formatEmailSubject(subject),
text: text,
attachments: attachments,
html: options.html || defaultHtml,
};
try {
// Send mail with defined transport object
let info = await transporter.sendMail(mailOptions);
console.info("Email sent successfully");
console.info("Message ID: ", info.messageId);
return true;
} catch (error) {
console.error("Error sending email: ", error);
return false;
}
}
async function sendBark(title, body, options = {}) {
if (!BARK_KEY) {
console.info("Bark推送密钥未配置");
return false;
}
const {
subtitle = "",
sound = "alarm",
group = "WeRead-Challenge",
icon = "",
url = "",
image = "",
level = "active"
} = options;
const barkUrl = `${BARK_SERVER}/${BARK_KEY}`;
const payload = { title, body, sound, group, level };
if (subtitle) payload.subtitle = subtitle;
if (icon) payload.icon = icon;
if (url) payload.url = url;
if (image) payload.image = image;
const jsonData = JSON.stringify(payload);
console.info("发送Bark推送:", barkUrl);
try {
const httpModule = barkUrl.startsWith("https://") ? https : http;
const urlObj = new URL(barkUrl);
return new Promise((resolve) => {
const req = httpModule.request({
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === "https:" ? 443 : 80),
path: urlObj.pathname,
method: "POST",
headers: {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(jsonData),
"User-Agent": "WeRead-Tracker/1.0",
},
}, (res) => {
let responseData = "";
res.on("data", (chunk) => { responseData += chunk; });
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.info("Bark推送发送成功");
resolve(true);
} else {
console.error(`Bark推送失败: ${res.statusCode} - ${responseData}`);
resolve(false);
}
});
});
req.on("error", (error) => {
console.error("Bark推送请求错误:", error.message);
resolve(false);
});
req.write(jsonData);
req.end();
});
} catch (error) {
console.error("Bark推送异常:", error);
return false;
}
}
function quoteShellArg(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}
function quotePlistString(value) {
return escapeHtml(String(value));
}
function escapePowerShellSingleQuoted(value) {
return String(value).replace(/'/g, "''");
}
function encodePowerShellCommand(script) {
return Buffer.from(script, "utf16le").toString("base64");
}
function parseCliArgs(argv) {
const args = [];
const flags = {};
for (let i = 0; i < argv.length; i += 1) {
const token = argv[i];
if (!token.startsWith("-")) {
args.push(token);
continue;
}
if (token === "--") {
args.push(...argv.slice(i + 1));
break;
}
const equalIndex = token.indexOf("=");
if (equalIndex > 0) {
const normalizedKey = token.slice(0, equalIndex).replace(/^-+/, "");
flags[normalizedKey] = token.slice(equalIndex + 1);
continue;
}
const normalizedKey = token.replace(/^-+/, "");
const nextToken = argv[i + 1];
if (nextToken && !nextToken.startsWith("-")) {
flags[normalizedKey] = nextToken;
i += 1;
} else {
flags[normalizedKey] = true;
}
}
return { args, flags };
}
function showHelp(command = "root") {
const sections = {
root: `
Usage:
weread-selenium-cli run [run-options]
weread-selenium-cli schedule --name --every [--workdir ] [--platform ] [--weread-duration ] [--dry-run]
weread-selenium-cli help [command]
weread-selenium-cli -h
Commands:
run Run the WeRead challenge flow.
schedule Generate recurring scheduled-task commands for this CLI.
help Show help for the CLI or a specific command.
Notes:
- Legacy alias 'weread-challenge' remains supported and maps to the same CLI entrypoint.
- No arguments still run the existing reading flow for compatibility, and print a migration hint for run.
- schedule accepts an optional working directory and defaults to the current user's home directory.
- schedule only appends --weread-duration when generating task commands.
- schedule only prints create/verify/rollback commands and never registers tasks directly.
- run options override environment variables with the same meaning.
`.trim(),
schedule: `
Usage:
weread-selenium-cli schedule --name --every [--workdir ] [--platform ] [--weread-duration ] [--dry-run]
Required:
--name Task name.
--every Repeat interval in minutes. Must be a positive integer.
Optional:
--workdir Absolute working directory used by the task. Defaults to the current user's home directory.
--platform windows | macos | linux. Defaults to current OS.
--weread-duration
Optional reading duration in minutes. This is the only run argument supported by schedule.
--dry-run Deprecated. schedule now only prints commands and never applies them.
-h, --help Show this help text.
Examples:
weread-selenium-cli schedule --name weread-hourly --every 60 --platform windows
weread-selenium-cli schedule --name weread-hourly --every 60 --workdir /Users/me/weread-challenge-selenium --platform macos --dry-run
weread-selenium-cli schedule --name weread-hourly --every 60 --weread-duration 10
Notes:
- schedule only prints create/verify/rollback commands and never registers tasks directly.
- On Windows, if the generated create command returns 'Access is denied', rerun it in an Administrator terminal.
- If --workdir is omitted, schedule uses the current user's home directory.
- schedule only supports appending --weread-duration to the generated run command.
- Local CLI runs prefer .weread, but reuse an existing ./data directory when WEREAD_DATA_DIR is not set.
`.trim(),
run: `
Usage:
weread-selenium-cli run [run-options]
Options:
-h, --help Show this help text.
Values below also accept their original env key form, for example --WEREAD_BROWSER.
${getRunOptionsHelpLines()}
Notes:
- Existing environment variables remain the configuration source.
- CLI run options override environment variables.
- Legacy alias 'weread-challenge' remains supported.
- Local CLI runs prefer .weread, but reuse an existing ./data directory when WEREAD_DATA_DIR is not set.
- Invoking the CLI with no arguments still behaves like run and prints a migration hint.
`.trim(),
};
const output = sections[command] || sections.root;
console.log(output);
}
function getDefaultPlatform() {
switch (process.platform) {
case "win32":
return "windows";
case "darwin":
return "macos";
default:
return "linux";
}
}
function requireFlag(flags, name) {
if (!flags[name]) {
throw new Error(`Missing required flag --${name}`);
}
return String(flags[name]);
}
function requireAbsolutePath(targetPath, flagName) {
if (!path.isAbsolute(targetPath)) {
throw new Error(`--${flagName} must be an absolute path`);
}
}
function parseOptionalScheduleDuration(flags) {
if (!Object.prototype.hasOwnProperty.call(flags, "weread-duration")) {
return null;
}
return parseIntegerValue(flags["weread-duration"], "weread-duration");
}
function buildScheduledRunCommand(duration) {
if (duration === null) {
return "weread-selenium-cli run";
}
return `weread-selenium-cli run --weread-duration ${duration}`;
}
function resolveScheduleConfig(flags) {
const platform = String(flags.platform || getDefaultPlatform()).toLowerCase();
if (!["windows", "macos", "linux"].includes(platform)) {
throw new Error(`Unsupported platform: ${platform}`);
}
const name = requireFlag(flags, "name");
const every = Number.parseInt(requireFlag(flags, "every"), 10);
if (!Number.isInteger(every) || every <= 0) {
throw new Error("--every must be a positive integer");
}
const rawWorkdir = flags.workdir ? String(flags.workdir) : os.homedir();
requireAbsolutePath(rawWorkdir, "workdir");
const workdir = path.resolve(rawWorkdir);
const dryRun = Boolean(flags["dry-run"]);
const wereadDuration = parseOptionalScheduleDuration(flags);
return {
platform,
name,
every,
workdir,
command: buildScheduledRunCommand(wereadDuration),
dryRun,
wereadDuration,
};
}
function getWindowsTaskRunCommand(workdir, command) {
const homeDir = path.resolve(os.homedir());
const workdirLiteral =
path.resolve(workdir).toLowerCase() === homeDir.toLowerCase()
? "%USERPROFILE%"
: workdir;
return `cmd /d /s /c "cd /d ${workdirLiteral}&&${command}"`;
}
function formatWindowsScheduleStartTime(date = new Date()) {
const scheduleStart = new Date(date.getTime() + 5 * 60 * 1000);
const hours = String(scheduleStart.getHours()).padStart(2, "0");
const minutes = String(scheduleStart.getMinutes()).padStart(2, "0");
return `${hours}:${minutes}`;
}
function getWindowsSchedulePlan(config) {
const taskName = config.name;
const taskRun = getWindowsTaskRunCommand(config.workdir, config.command);
const startTime = formatWindowsScheduleStartTime();
const repetitionDuration = "8760:00";
return {
platform: "windows",
applyCommands: [
`schtasks /Create /F /TN "${taskName}" /SC DAILY /MO 1 /ST ${startTime} /RI ${config.every} /DU ${repetitionDuration} /TR '${taskRun}'`,
],
verifyCommands: [
`schtasks /Query /TN "${taskName}" /V /FO LIST`,
],
rollbackCommands: [
`schtasks /Delete /F /TN "${taskName}"`,
],
summaryLines: [
`Platform: windows`,
`Task Name: ${config.name}`,
`Interval: every ${config.every} minute(s)`,
`Start Time: ${startTime}`,
`Working Directory: ${config.workdir}`,
`Command: ${config.command}`,
`Data Directory: ${path.join(config.workdir, ".weread")} (or existing ${path.join(config.workdir, "data")} in compatibility mode)`,
`Trigger: daily at ${startTime}, then every ${config.every} minute(s) for ${repetitionDuration}`,
],
};
}
function getMacosSchedulePlan(config) {
const sanitizedName = config.name.replace(/[^A-Za-z0-9_.-]/g, "-");
const plistPath = path.join(
os.homedir(),
"Library",
"LaunchAgents",
`dev.techfetch.weread.${sanitizedName}.plist`
);
const plistContent = `
Label
dev.techfetch.weread.${quotePlistString(sanitizedName)}
ProgramArguments
/bin/sh
-lc
${quotePlistString(config.command)}
WorkingDirectory
${quotePlistString(config.workdir)}
RunAtLoad
StartInterval
${config.every * 60}
`;
return {
platform: "macos",
plistPath,
plistContent,
applyCommands: [
`mkdir -p ${quoteShellArg(path.dirname(plistPath))}`,
`cat <<'EOF' > ${quoteShellArg(plistPath)}\n${plistContent}\nEOF`,
`launchctl unload ${quoteShellArg(plistPath)} 2>/dev/null || true`,
`launchctl load -w ${quoteShellArg(plistPath)}`,
],
verifyCommands: [
`launchctl list | grep ${quoteShellArg(`dev.techfetch.weread.${sanitizedName}`)}`,
`plutil -lint ${quoteShellArg(plistPath)}`,
],
rollbackCommands: [
`launchctl unload -w ${quoteShellArg(plistPath)} 2>/dev/null || true`,
`rm -f ${quoteShellArg(plistPath)}`,
],
summaryLines: [
`Platform: macos`,
`Task Name: ${config.name}`,
`Interval: every ${config.every} minute(s)`,
`Working Directory: ${config.workdir}`,
`Command: ${config.command}`,
`LaunchAgent: ${plistPath}`,
],
};
}
function getLinuxSchedulePlan(config) {
const sanitizedName = config.name.replace(/[^A-Za-z0-9_.-]/g, "-");
const unitDir = path.join(os.homedir(), ".config", "systemd", "user");
const servicePath = path.join(unitDir, `${sanitizedName}.service`);
const timerPath = path.join(unitDir, `${sanitizedName}.timer`);
const serviceContent = `[Unit]
Description=WeRead Challenge ${sanitizedName}
[Service]
Type=simple
WorkingDirectory=${config.workdir}
ExecStart=/bin/sh -lc ${quoteShellArg(config.command)}
`;
const timerContent = `[Unit]
Description=Run ${sanitizedName} every ${config.every} minute(s)
[Timer]
OnBootSec=1min
OnUnitActiveSec=${config.every}min
Unit=${sanitizedName}.service
[Install]
WantedBy=timers.target
`;
return {
platform: "linux",
servicePath,
timerPath,
serviceContent,
timerContent,
applyCommands: [
`mkdir -p ${quoteShellArg(unitDir)}`,
`cat <<'EOF' > ${quoteShellArg(servicePath)}\n${serviceContent}\nEOF`,
`cat <<'EOF' > ${quoteShellArg(timerPath)}\n${timerContent}\nEOF`,
`systemctl --user daemon-reload`,
`systemctl --user enable --now ${quoteShellArg(`${sanitizedName}.timer`)}`,
],
verifyCommands: [
`systemctl --user status ${quoteShellArg(`${sanitizedName}.timer`)}`,
`systemctl --user list-timers ${quoteShellArg(`${sanitizedName}.timer`)}`,
],
rollbackCommands: [
`systemctl --user disable --now ${quoteShellArg(`${sanitizedName}.timer`)} || true`,
`rm -f ${quoteShellArg(servicePath)} ${quoteShellArg(timerPath)}`,
`systemctl --user daemon-reload`,
],
summaryLines: [
`Platform: linux`,
`Task Name: ${config.name}`,
`Interval: every ${config.every} minute(s)`,
`Working Directory: ${config.workdir}`,
`Command: ${config.command}`,
`Service: ${servicePath}`,
`Timer: ${timerPath}`,
],
};
}
function buildSchedulePlan(config) {
switch (config.platform) {
case "windows":
return getWindowsSchedulePlan(config);
case "macos":
return getMacosSchedulePlan(config);
case "linux":
return getLinuxSchedulePlan(config);
default:
throw new Error(`Unsupported platform: ${config.platform}`);
}
}
function getScheduleNotes(plan) {
const notes = [
"schedule only prints commands. It does not create the scheduled task for you.",
"Generated commands use weread-selenium-cli run. Existing weread-challenge tasks remain valid through the legacy bin alias.",
"If WEREAD_DATA_DIR is not set, runtime prefers .weread and reuses an existing ./data directory for compatibility.",
];
if (plan.platform === "windows") {
notes.push(
"Windows task creation may require an elevated terminal. If the create command returns 'Access is denied', rerun it in an Administrator PowerShell or Command Prompt."
);
}
return notes;
}
function printSchedulePlan(plan, config) {
console.log(plan.summaryLines.join("\n"));
console.log("");
console.log("Create:");
console.log(plan.applyCommands.join("\n"));
console.log("");
console.log("Verify:");
console.log(plan.verifyCommands.join("\n"));
console.log("");
console.log("Rollback:");
console.log(plan.rollbackCommands.join("\n"));
console.log("");
console.log("Notes:");
console.log(getScheduleNotes(plan).join("\n"));
if (config.dryRun) {
console.log("Deprecated flag: --dry-run has no additional effect because schedule is already output-only.");
}
}
async function handleScheduleCommand(rawArgs) {
const parsed = parseCliArgs(rawArgs);
if (parsed.flags.h || parsed.flags.help || parsed.args[0] === "help") {
showHelp("schedule");
return;
}
const allowedFlags = new Set([
"name",
"every",
"workdir",
"platform",
"weread-duration",
"dry-run",
"h",
"help",
]);
const unsupportedFlags = Object.keys(parsed.flags).filter((key) => !allowedFlags.has(key));
if (unsupportedFlags.length > 0) {
throw new Error(
`Unsupported schedule flag(s): ${unsupportedFlags.map((key) => `--${key}`).join(", ")}. schedule only supports --weread-duration in addition to its own task flags.`
);
}
const config = resolveScheduleConfig(parsed.flags);
if (config.platform !== getDefaultPlatform()) {
throw new Error(
`Cannot manage ${config.platform} schedule on ${getDefaultPlatform()}. Run this command on the target OS.`
);
}
const plan = buildSchedulePlan(config);
printSchedulePlan(plan, config);
}
async function dispatchCli(argv) {
const [command, ...restArgs] = argv;
const looksLikeCompatRun = !command || (command.startsWith("-") && command !== "-h" && command !== "--help");
if (looksLikeCompatRun) {
const compatArgs = command ? argv : [];
const parsed = parseCliArgs(compatArgs);
if (parsed.flags.h || parsed.flags.help) {
showHelp("run");
return;
}
if (parsed.args.length > 0) {
throw new Error(`Unexpected positional arguments for run: ${parsed.args.join(" ")}`);
}
applyRunCliOverrides(parsed.flags);
initializeRuntime();
console.warn(
"No subcommand provided. Running in compatibility mode as 'run'. Please switch to 'weread-selenium-cli run'."
);
await runMain();
return;
}
if (command === "-h" || command === "--help" || command === "help") {
showHelp(restArgs[0] || "root");
return;
}
if (command === "run") {
const parsed = parseCliArgs(restArgs);
if (parsed.flags.h || parsed.flags.help) {
showHelp("run");
return;
}
if (parsed.args.length > 0) {
throw new Error(`Unexpected positional arguments for run: ${parsed.args.join(" ")}`);
}
applyRunCliOverrides(parsed.flags);
initializeRuntime();
await runMain();
return;
}
if (command === "schedule") {
await handleScheduleCommand(restArgs);
return;
}
throw new Error(`Unknown command: ${command}`);
}
async function runMain() {
console.info("Starting the script, datetime: ", new Date());
let driver;
// 发送脚本启动通知
await sendBark("微信读书挑战", "自动阅读脚本开始运行", {
subtitle: "脚本启动",
level: "active",
sound: "beginning"
});
try {
const capabilities = {
browserName: WEREAD_BROWSER,
pageLoadStrategy: 'eager',
};
var browser;
switch (WEREAD_BROWSER) {
case Browser.CHROME:
browser = require("selenium-webdriver/chrome");
break;
case Browser.EDGE:
browser = require("selenium-webdriver/edge");
break;
case Browser.FIREFOX:
browser = require("selenium-webdriver/firefox");
break;
case Browser.SAFARI:
browser = require("selenium-webdriver/safari");
break;
default:
browser = require("selenium-webdriver/chrome");
break;
}
let options = new browser.Options();
switch (WEREAD_BROWSER) {
case Browser.CHROME:
case Browser.EDGE:
options.addArguments("--no-sandbox");
options.addArguments("--disable-gpu");
options.addArguments("--disable-dev-shm-usage");
options.addArguments("--profile-directory=" + WEREAD_USER);
options.addArguments("--disable-infobars");
options.addArguments("--disable-extensions");
options.addArguments("--disable-notifications");
options.addArguments("--disable-popup-blocking");
// check if WEREAD_REMOTE_BROWSER is empty
if (WEREAD_REMOTE_BROWSER) {
// 远端启动前做一次健康检查
await checkSeleniumHealth(WEREAD_REMOTE_BROWSER);
// Ensure the remote browser URL has a protocol
let remoteBrowserUrl = WEREAD_REMOTE_BROWSER;
if (!remoteBrowserUrl.startsWith("http://") && !remoteBrowserUrl.startsWith("https://")) {
remoteBrowserUrl = "http://" + remoteBrowserUrl;
}
console.info("WEREAD_REMOTE_BROWSER: ", remoteBrowserUrl);
driver = await new Builder()
.usingServer(remoteBrowserUrl)
.forBrowser(WEREAD_BROWSER)
.withCapabilities(capabilities)
.setChromeOptions(options)
.build();
} else {
console.info("WEREAD_REMOTE_BROWSER not found. Running locally.");
driver = await new Builder()
.forBrowser(WEREAD_BROWSER)
.withCapabilities(capabilities)
.setChromeOptions(options)
.build();
}
break;
case Browser.FIREFOX:
driver = await new Builder().forBrowser(Browser.FIREFOX).build();
break;
case Browser.SAFARI:
driver = await new Builder()
.forBrowser(Browser.SAFARI)
.setSafariOptions(options)
.build();
break;
default:
break;
}
// 全局超时配置,避免单次命令长时间挂起
await driver.manage().setTimeouts({
implicit: 5000,
pageLoad: 60000,
script: 30000,
});
console.info("Browser launched successfully.");
// set screen size
randomWidth = Math.floor(Math.random() * 1000) + 800;
randomHeight = Math.floor(Math.random() * 800) + 700;
await driver
.manage()
.window()
.setRect({ width: randomWidth, height: randomHeight });
await driver.get(WEREAD_URL);
if (fs.existsSync(getCookieFilePath())) {
await loadCookies(driver, getCookieFilePath());
await driver.navigate().refresh(); // Refresh to apply cookies
}
console.info("Going to the URL:", WEREAD_URL);
let title = await driver.getTitle();
assert.equal("微信读书", title);
console.info("Successfully opened the url:", WEREAD_URL);
// create dir data if not exists
ensureDataDir();
// Check if "Login" hyperlink exists
console.info("Find login links...");
let loginLinks = await driver.findElements(
By.xpath("//a[contains(text(), '登录')]"),
10000
);
if (loginLinks.length > 0) {
console.info("Login link found. Clicking...");
// 避免点击不成功
await new Promise((resolve) => setTimeout(resolve, 1000));
await loginLinks[0].click();
// 等待页面加载
await new Promise((resolve) => setTimeout(resolve, 2000));
// 使用简化的二维码定位函数
let qrElementFound = await findQRCodeElement(driver);
// 如果找到任何二维码相关元素,保存截图
if (qrElementFound) {
// 避免截图时二维码还未弹出
await new Promise((resolve) => setTimeout(resolve, 1000));
// save screenshot of QR code
await driver.takeScreenshot().then((image, err) => {
fs.writeFileSync(getLoginQrCodePath(), image, "base64");
});
console.info("QR code saved, datetime: ", new Date());
await extractAndDisplayQRCode(driver);
} else {
console.error("未能找到任何二维码相关元素");
}
}
let locator1 = By.xpath(
"//div[contains(text(), '点击刷新二维码') and @class='wr_login_modal_qr_overlay_text']"
);
let locator2 = By.xpath(
"//div[contains(text(), '我的书架') and @class='wr_index_page_top_section_header_action_link']"
);
let maxRetries = 3;
while (maxRetries-- > 0) {
console.info("Waiting for login...");
const element = await driver.wait(
new Promise((resolve, reject) => {
driver
.wait(until.elementLocated(locator1), 300000)
.then(resolve)
.catch(() => { });
driver
.wait(until.elementLocated(locator2), 300000)
.then(resolve)
.catch(() => { });
}),
300000 // 5 minutes
);
if (element === undefined) {
console.info("no element found");
continue;
}
let text = await element.getText();
// if text contains "我的书架", then login is successful
if (text.includes("我的书架")) {
console.info("Login completed.");
break;
}
// 如果出现二维码过期提示,则自动刷新
if (QR_EXPIRED_TEXTS.some((expiredText) => text.includes(expiredText))) {
console.info("Refreshing QR code...");
let refreshSuccess = await refreshQRCode(driver);
if (!refreshSuccess) {
console.error("二维码刷新失败,尝试其他方法...");
// 如果刷新失败,尝试直接刷新页面
await driver.navigate().refresh();
await new Promise((resolve) => setTimeout(resolve, 3000));
// 再次检查二维码
let qrElementFound = await findQRCodeElement(driver);
if (qrElementFound) {
await new Promise((resolve) => setTimeout(resolve, 1000));
await driver.takeScreenshot().then((image, err) => {
fs.writeFileSync(getLoginQrCodePath(), image, "base64");
});
console.info("页面刷新后找到二维码, datetime: ", new Date());
await extractAndDisplayQRCode(driver);
}
}
continue;
}
}
if (maxRetries <= 0) {
console.error("Failed to login.");
if (ENABLE_EMAIL) {
const loginFailureAttachments = fs.existsSync(getLoginQrCodePath())
? [getLoginQrCodePath()]
: [];
await sendMail(
"[项目进展--项目停滞]",
"Failed to login.",
loginFailureAttachments,
{
extraHtml: loginFailureAttachments.length
? `
The current login QR code is attached below. Scan it to complete login.
`
: "",
}
);
}
await sendBark("微信读书挑战", "登录失败", {
subtitle: "项目停滞",
level: "critical",
sound: "alarm"
});
return;
}
console.info("Successfully logged in.");
// If cookies exist, save them
await saveCookies(driver, getCookieFilePath());
if (WEREAD_AGREE_TERMS) {
logEventToWereadLog("");
}
// Find the first div with class "wr_index_mini_shelf_card"
let selection = Number(WEREAD_SELECTION);
if (selection === -1) {
console.info("WEREAD_SELECTION=-1,直接打开 DEFAULT_BOOK_URL:", DEFAULT_BOOK_URL);
await driver.get(DEFAULT_BOOK_URL);
} else {
if (selection === 0) {
// random selection between 1 and 4
selection = Math.floor(Math.random() * 4) + 1;
}
let books = await driver.findElements(
// By.xpath("(//div[@class='wr_index_mini_shelf_card'])[" + selection + "]"),
By.xpath("//div[@class='wr_index_mini_shelf_card']"),
10000
);
if (books.length > 0 && books.length < selection) {
await books[0].click();
console.info("Clicked on the first book.");
} else if (books.length >= selection) {
await books[selection - 1].click();
console.info("Clicked on the ", selection, "th book.");
} else {
console.warn("No book link found. Using the default link.");
await driver.get(DEFAULT_BOOK_URL);
}
}
// get button with title equal to "目录"
await driver.wait(
until.elementLocated(By.xpath('//button[@title="目录"]')),
10000
);
// 切换到"上下滚动阅读"模式
// OLD: 通过 title="切换到上下滚动阅读" 定位
// NEW: 通过 class "readerControls_item" + "isHorizontalReader" 定位
let switchButton = await driver.findElements(
By.xpath(
"//button[@title='切换到上下滚动阅读'] | //button[contains(@class, 'readerControls_item') and contains(@class, 'isHorizontalReader')]"
)
);
if (switchButton.length > 0) {
await switchButton[0].click();
console.info("Switched to vertical scroll mode.");
} else {
console.warn('未找到用于切换为上下滚动阅读的按钮(兼容新老版本定位)');
}
// Wait for button with title "目录"
await driver.wait(
until.elementLocated(By.xpath('//button[@title="目录"]')),
10000
);
console.info("Successfully switched to vertical scroll mode.");
if (ENABLE_EMAIL) {
await driver
.takeScreenshot()
.then((image, err) =>
fs.writeFileSync(getScreenshotPath(), image, "base64")
);
await sendMail("[项目进展--项目启动]", "Login successful.", [
getScreenshotPath(),
]);
}
await sendBark("微信读书挑战", "登录成功", {
subtitle: "项目启动",
level: "active",
sound: "birdsong"
});
// run script to keep reading
// let script = fs.readFileSync("./src/keep_reading.js", "utf8");
// await driver.executeScript(script);
console.info("Reading started...");
// duration from environment variable, WEREAD_DURATION in minutes
console.info("Reading duration: ", WEREAD_DURATION, " minutes");
console.info(
"阅读期间截图: ",
WEREAD_SCREENSHOT ? "开启" : "关闭"
);
let startTime = new Date();
console.info("Start time: ", startTime);
let endTime = new Date(startTime.getTime() + WEREAD_DURATION * 60000);
console.info("End time: ", endTime);
let screenshotTime = startTime;
// log last read time per minute
while (new Date() < endTime) {
let currentTime = new Date();
// wait for random time between 300ms to 1s
let randomTime = Math.floor(Math.random() * 700) + 300;
if (WEREAD_SPEED === "fast") {
randomTime = Math.floor(Math.random() * 100) + 100;
} else if (WEREAD_SPEED === "normal") {
randomTime = Math.floor(Math.random() * 400) + 200;
}
await new Promise((resolve) => setTimeout(resolve, randomTime));
if (currentTime.getMinutes() !== screenshotTime.getMinutes()) {
// take screenshot every minute, and get round index
let screenshotIndex = Math.round((currentTime - startTime) / 60000);
const screenshotPath = getScreenshotPath(`screenshot-${screenshotIndex}.png`);
if (WEREAD_SCREENSHOT) {
await driver.takeScreenshot().then((image, err) => {
fs.writeFileSync(screenshotPath, image, "base64");
});
}
screenshotTime = currentTime;
console.info("Reading minute: ", screenshotIndex);
// if the screenshot png size is less than 100 KB, then refresh the page
// continue if file not found
if (!WEREAD_SCREENSHOT) {
continue;
}
if (!fs.existsSync(screenshotPath)) {
continue;
}
let stats = fs.statSync(screenshotPath);
let fileSizeInBytes = stats.size;
let fileSizeInKB = fileSizeInBytes / 1024;
console.debug("Screenshot size: ", fileSizeInKB, " KB");
if (fileSizeInKB < 100) {
await driver.navigate().refresh();
console.info("Page refreshed.");
}
}
// check if need to jump to the top
// check if the doc title contains "已读完"
let title = await driver.getTitle();
let needToJump = title.includes("已读完");
const needToJumpReasons = [];
if (needToJump) {
needToJumpReasons.push('标题包含 "已读完"');
}
// check if got a "span" contains text "开通后即可阅读"
let openBook = await driver.findElements(
By.xpath("//span[contains(text(), '开通后即可阅读')]")
);
if (openBook.length > 0) {
console.warn("需要打开书籍");
needToJump = true;
needToJumpReasons.push("需要打开书籍");
}
// find element div with class "readerFooter_ending_title" and content contains "全 书 完"
let readComplete = await driver.findElements(
By.xpath("//div[contains(text(), '全 书 完')]")
);
if (readComplete.length > 0) {
console.warn("书籍已读完");
needToJump = true;
needToJumpReasons.push("书籍已读完");
}
if (needToJump) {
console.warn(
"needToJump = true, reasons: " +
(needToJumpReasons.length
? needToJumpReasons.join(" | ")
: "unknown")
);
// jump to the top
// click the buttion "目录"
let catalogs = await driver.findElements(
By.xpath('//button[@title="目录"]')
);
if (catalogs.length > 0) {
await catalogs[0].click();
console.info("Clicked on catalog button.");
} else {
console.error("Catalog button not found.");
}
// click the first "li" with class "readerCatalog_list_item"
let chapters = await driver.findElements(
By.xpath("//li[@class='readerCatalog_list_item']")
);
if (chapters.length > 0) {
// scroll to the top
await driver.executeScript(
"arguments[0].scrollIntoView();",
chapters[0]
);
await chapters[1].click();
console.info("Clicked on first chapter.");
} else {
console.error("Chapters not found.");
}
}
// find button with title "下一章" or "下一页"
let nextChapter = await driver.findElements(
By.xpath("//button[@title='下一章'] | //button[@title='下一页']")
);
if (nextChapter.length !== 0) {
// check if the button is shown on the screen
let isVisible = await isElementInViewport(driver, nextChapter[0]);
if (isVisible) {
await nextChapter[0].click();
console.info("Clicked on next chapter button.");
continue;
}
}
// find div with content contains "点击重试", 未确认
let retry = await driver.findElements(
By.xpath("//div[contains(text(), '点击重试')]")
);
if (retry.length > 0) {
console.warn("Retry button found.");
await retry[0].click();
console.info("Clicked on retry button.");
continue;
}
// press down arrow key if position is greater than 99
await pressDownArrow(driver);
console.debug("Pressed down arrow key.");
}
console.info("Reading completed.");
// save cookies after reading
await saveCookies(driver, getCookieFilePath());
if (ENABLE_EMAIL) {
await driver
.takeScreenshot()
.then((image, err) =>
fs.writeFileSync(getScreenshotPath(), image, "base64")
);
await sendMail("[项目进展--项目完成]", "Reading completed.", [
getScreenshotPath(),
]);
}
await sendBark("微信读书挑战", `阅读完成,持续时间:${WEREAD_DURATION}分钟`, {
subtitle: "项目完成",
level: "active",
sound: "success"
});
} catch (e) {
// Add line number to error message if possible
let errorMessage = String(e?.message || e || "Unknown error");
if (e && e.stack) {
const match = e.stack.match(/(src\/main.js):(\d+):(\d+)/);
if (match) {
errorMessage += ` (at ${match[1]}:${match[2]})`;
}
}
console.info(errorMessage);
// 出错时抓取 selenium 健康状态与容器日志
await collectDiagnostics(errorMessage);
if (ENABLE_EMAIL) {
await sendMail("[项目进展--项目停滞]", "Error occurred: " + errorMessage);
}
await sendBark("微信读书挑战", `发生错误:${errorMessage.substring(0, 100)}${errorMessage.length > 100 ? '...' : ''}`, {
subtitle: "项目停滞",
level: "critical",
sound: "alarm"
});
if (WEREAD_AGREE_TERMS) {
logEventToWereadLog(errorMessage);
}
// wait for 3 seconds before closing the browser
await new Promise((resolve) => setTimeout(resolve, 3000));
} finally {
// cleanup
console.info("Quitting the browser...");
if (driver != undefined && driver != null) {
await driver.quit();
console.info("Browser closed.");
}
process.exit(0);
}
}
function getRuntimeConfigSnapshot() {
return {
DEBUG,
WEREAD_USER,
WEREAD_REMOTE_BROWSER,
WEREAD_DURATION,
WEREAD_SPEED,
WEREAD_SELECTION,
WEREAD_BROWSER,
ENABLE_EMAIL,
WEREAD_SCREENSHOT,
WEREAD_AGREE_TERMS,
EMAIL_PORT,
BARK_KEY,
BARK_SERVER,
WEREAD_DATA_DIR,
DEFAULT_BOOK_URL,
EMAIL_SMTP: process.env.EMAIL_SMTP || "",
EMAIL_USER: process.env.EMAIL_USER || "",
EMAIL_PASS: process.env.EMAIL_PASS || "",
EMAIL_FROM: process.env.EMAIL_FROM || "",
EMAIL_TO: process.env.EMAIL_TO || "",
};
}
module.exports = {
RUN_OPTION_SPECS,
applyRunCliOverrides,
getRuntimeConfigSnapshot,
parseCliArgs,
setRuntimeConfigFromEnv,
};
if (require.main === module) {
dispatchCli(process.argv.slice(2)).catch((error) => {
console.error(error.message || error);
process.exit(1);
});
}