├── .env ├── .gitignore ├── LICENSE ├── README.md ├── assets ├── env.png ├── incomplete.png ├── login.png └── setup.png ├── package.json ├── pnpm-lock.yaml └── src ├── apis ├── app │ ├── book.ts │ ├── friend.ts │ ├── readdata.ts │ ├── user.ts │ └── weekly.ts ├── err-code.ts └── web │ ├── book.ts │ ├── category.ts │ ├── login.ts │ ├── misc.ts │ ├── pay.ts │ ├── review.ts │ ├── shelf.ts │ ├── upload.ts │ └── user.ts ├── config.ts ├── cron ├── common.ts ├── exchange.ts └── read.ts ├── database ├── bookid.ts ├── db.ts ├── download.ts └── log.ts ├── deps.ts ├── frontend ├── apis │ ├── common.ts │ ├── downloadSSE.ts │ ├── loginSSE.ts │ ├── misc.ts │ ├── review.ts │ ├── shelf.ts │ ├── task.ts │ └── user.ts ├── assets │ ├── js │ │ └── footer_note.js │ └── styles │ │ ├── footer_note.css │ │ └── reset.css └── www │ ├── detail.html │ ├── index.html │ ├── lib │ ├── FileSaver.min.js │ ├── jszip.min.js │ └── qrcode.min.js │ ├── loading.html │ ├── login.html │ ├── read.html │ ├── review.html │ ├── search.html │ ├── style │ ├── app.css │ ├── common.css │ ├── detail.css │ ├── index.css │ ├── loading.css │ ├── login.css │ ├── reset.css │ ├── review.css │ └── search.css │ └── user.html ├── kv ├── credential.ts ├── db.ts ├── download.ts └── task.ts ├── router.ts ├── server.ts └── utils ├── decrypt.ts ├── encode.ts ├── html.ts ├── index.ts ├── process.ts ├── request.ts └── style.ts /.env: -------------------------------------------------------------------------------- 1 | # Supabase 数据库连接字符串 2 | DATABASE_URL= 3 | 4 | # Deno KV Access Token 5 | DENO_KV_ACCESS_TOKEN= 6 | 7 | # Deno KV UUID 8 | DENO_KV_UUID= 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .env 3 | .husky/pre-push 4 | .DS_Store 5 | node_modules 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 champ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wereadx 2 | 3 | 微信读书辅助工具,基于微信读书网页版开发的额外功能 4 | 5 | ## 功能列表 6 | 7 | 1. 下载书架上的书到本地,目前仅支持下载 html 格式 8 | 2. 自动更新阅读时长,可用于刷“读书排行榜”或者“阅读挑战赛” 9 | 3. 每周日晚 23:30 自动领取“时长兑福利”中的免费体验卡(暂未对外开放) 10 | 4. 支持下载用户上传的 pdf 格式的书(不计入下载次数限制,因为走的是外部的流量) 11 | 12 | > 如果需要更多功能,可以在issue区讨论 13 | 14 | 15 | ## 环境变量说明 16 | 请在项目根目录下新建`.env`文件,存放以下环境变量: 17 | ``` 18 | # Supabase 数据库连接字符串 19 | DATABASE_URL= 20 | 21 | # Deno KV Access Token 22 | DENO_KV_ACCESS_TOKEN= 23 | 24 | # Deno KV UUID 25 | DENO_KV_UUID= 26 | ``` 27 | 28 | ## 部署指南 29 | 30 | > 如果想要自己部署,可参考以下步骤进行部署,目前仅支持部署到 Deno Deploy。 31 | > 如果不想自己部署,可以使用 https://weread.deno.dev 公共服务,但有会限制,比如下载次数限制为每月100次,不支持自动领取体验卡等。 32 | 33 | ### 1. fork 本项目 34 | 35 | ### 2. 在根目录创建`.env`文件,内容按照上面的说明填写 36 | 37 | ### 3. 新建 Deno Deploy 项目,配置如下: 38 | ![项目配置](assets/setup.png) 39 | 40 | ### 4. 部署完成,在 Deno Deploy 的设置页面,添加环境变量 41 | ![环境变量配置](assets/env.png) 42 | 43 | 44 | ## 特别注意 45 | 46 | ### 1. 关于付费内容 47 | 本项目不支持下载 **需要付费才能查看** 的内容,该内容通常表现为每章只有开头的一段内容,后面跟着省略号,如下图所示: 48 | 49 | ![需要付费才能查看的内容](assets/incomplete.png) 50 | 51 | ### 2. 关于双重验证码 52 | 53 | 扫码登录时会提示下面的二次确认,但实际上并不需要输入这个验证码也可以登录成功。 54 | 55 | ![登录时二次确认](assets/login.png) 56 | 57 | 这个应该是属于微信读书的bug,后续如果微信读书调整的话,我会跟进处理这个问题。 58 | 59 | 60 | ## 后续计划 61 | 62 | - 修复部分图片无法加载的问题; 63 | - 美化网站样式; 64 | - 添加更多微信读书API,比如导出笔记、书评等; 65 | - 支持下载更多电子书格式,比如 epub/azw3 等,[可以关注这个issue。](https://github.com/champkeh/wereadx/issues/2) 66 | - 加入搜索功能,方便下载非书架上的书(因为[技术限制](https://github.com/champkeh/wereadx/issues/3),并不保证能搜索到所有的书)。 67 | -------------------------------------------------------------------------------- /assets/env.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/88825/wereadx/ae13a14ed35bbbc2a4694921a4caa49403eb8e61/assets/env.png -------------------------------------------------------------------------------- /assets/incomplete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/88825/wereadx/ae13a14ed35bbbc2a4694921a4caa49403eb8e61/assets/incomplete.png -------------------------------------------------------------------------------- /assets/login.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/88825/wereadx/ae13a14ed35bbbc2a4694921a4caa49403eb8e61/assets/login.png -------------------------------------------------------------------------------- /assets/setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/88825/wereadx/ae13a14ed35bbbc2a4694921a4caa49403eb8e61/assets/setup.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "weread", 3 | "version": "1.0.1", 4 | "repository": "git@github.com:champkeh/weread.git", 5 | "author": "champkeh ", 6 | "license": "MIT", 7 | "scripts": { 8 | "dev": "deno run -A --unstable src/server.ts", 9 | "style": "deno run -A test/style.ts", 10 | "html": "deno run -A test/html.ts", 11 | "clean": "deno run -A --unstable scripts/clean.ts", 12 | "read": "deno run -A --unstable scripts/read.ts", 13 | "check:type": "deno check --unstable src/**/*.ts", 14 | "lint": "deno lint src/**/*.ts", 15 | "fmt": "deno fmt src/**/*.ts", 16 | "logs": "deno run -A scripts/logs.ts", 17 | "compile:pre-push": "deno compile -A --output .husky/pre-push scripts/pre-push.ts", 18 | "prepare": "husky install", 19 | "webnovel": "deno run -A --unstable src/apis/webnovel/chapter.ts" 20 | }, 21 | "devDependencies": { 22 | "husky": "^8.0.0" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: 5.4 2 | 3 | specifiers: 4 | husky: ^8.0.0 5 | 6 | devDependencies: 7 | husky: 8.0.3 8 | 9 | packages: 10 | 11 | /husky/8.0.3: 12 | resolution: {integrity: sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==} 13 | engines: {node: '>=14'} 14 | hasBin: true 15 | dev: true 16 | -------------------------------------------------------------------------------- /src/apis/app/book.ts: -------------------------------------------------------------------------------- 1 | import {postJSON, get} from "../../utils/request.ts"; 2 | import {UserAgentForApp} from "../../config.ts"; 3 | 4 | enum ReadingStatus { 5 | /** 6 | * 在读 7 | */ 8 | Reading = 2, 9 | 10 | /** 11 | * 读过 12 | */ 13 | Readed = 3, 14 | 15 | /** 16 | * 读完 17 | */ 18 | Finished = 4, 19 | } 20 | /** 21 | * 标记阅读状态 22 | * @param bookId 23 | * @param status 24 | * @param isCancel 25 | * @param vid 26 | * @param skey 27 | */ 28 | export async function book_markstatus(bookId: string, status: ReadingStatus, isCancel = false, vid: number | string, skey: string) { 29 | const resp = await postJSON("https://i.weread.qq.com/book/markstatus", { 30 | status: status, 31 | bookId: bookId, 32 | isCancel: isCancel ? 1 : 0, 33 | finishInfo: 0, 34 | }, { 35 | vid: vid.toString(), 36 | skey: skey, 37 | "User-Agent": UserAgentForApp, 38 | v: '7.4.2.23' 39 | }) 40 | return resp.json() 41 | } 42 | 43 | export async function book_chapter_download(vid: number | string, skey: string) { 44 | const resp = await get("https://i.weread.qq.com/book/chapterdownload", { 45 | bookId: "3300071749", 46 | bookType: "epub", 47 | bookVersion: "462704133", 48 | chapters: "311-320", 49 | modernVersion: "7.4.2.23", 50 | offline: 1, 51 | pf: "weread_wx-2001-iap-2001-iphone", 52 | pfkey: "pfkey", 53 | release: 1, 54 | screenSize: "16x9", 55 | synckey: "462704133", 56 | zoneId: 1, 57 | }, { 58 | vid: vid.toString(), 59 | skey: skey, 60 | "User-Agent": UserAgentForApp, 61 | v: '7.4.2.23' 62 | }) 63 | console.log(resp.headers) 64 | return resp.text() 65 | } 66 | -------------------------------------------------------------------------------- /src/apis/app/friend.ts: -------------------------------------------------------------------------------- 1 | import {get} from "../../utils/request.ts"; 2 | import {UserAgentForApp} from "../../config.ts"; 3 | 4 | /** 5 | * 查询阅读排名 6 | * @param vid 用户id 7 | * @param skey 用户凭证 8 | * 9 | * @example 异常返回 10 | * { errcode: -2010, errlog: "C3LkAop", errmsg: "用户不存在" } 11 | * { errcode: -2012, errlog: "C6rM756", errmsg: "登录超时" } 12 | */ 13 | export async function friend_ranking(vid: string | number, skey: string) { 14 | const resp = await get("https://i.weread.qq.com/friend/ranking", {}, { 15 | vid: vid.toString(), 16 | skey: skey, 17 | "User-Agent": UserAgentForApp, 18 | }) 19 | return resp.json() 20 | } 21 | -------------------------------------------------------------------------------- /src/apis/app/readdata.ts: -------------------------------------------------------------------------------- 1 | import {get} from "../../utils/request.ts"; 2 | import {UserAgentForApp} from "../../config.ts"; 3 | 4 | type ReadDataMode = "weekly" | "monthly" | "anually" | "overall" 5 | 6 | export async function readdata_detail(vid: number | string, skey: string, mode = "overall") { 7 | const resp = await get("https://i.weread.qq.com/readdata/detail", { 8 | baseTime: "0", 9 | defaultPreferBook: "0", 10 | mode: mode, 11 | }, { 12 | vid: vid.toString(), 13 | skey: skey, 14 | "User-Agent": UserAgentForApp, 15 | v: '7.4.2.23' 16 | }) 17 | return resp.json() 18 | } 19 | 20 | export async function challenge_detail(vid: number | string, skey: string) { 21 | const resp = await get("https://i.weread.qq.com/challenge/detail", { 22 | scene: "1" 23 | }, { 24 | vid: vid.toString(), 25 | skey: skey, 26 | "User-Agent": UserAgentForApp, 27 | v: '7.4.2.23' 28 | }) 29 | return resp.json() 30 | } 31 | -------------------------------------------------------------------------------- /src/apis/app/user.ts: -------------------------------------------------------------------------------- 1 | import {get, postJSON} from "../../utils/request.ts"; 2 | import {UserAgentForApp} from "../../config.ts"; 3 | 4 | /** 5 | * @example 返回示例 6 | * { errcode: -2013, errlog: "C4oh4r3", errmsg: "鉴权失败" } 7 | */ 8 | export async function login(refreshToken: string) { 9 | const resp = await postJSON("https://i.weread.qq.com/login", { 10 | deviceId: "1", 11 | refCgi: "", 12 | refreshToken: refreshToken, 13 | }, { 14 | "User-Agent": UserAgentForApp, 15 | }) 16 | return resp.json() 17 | } 18 | 19 | export async function profile(vid: number | string, skey: string) { 20 | const resp = await get("https://i.weread.qq.com/user/profile", { 21 | articleBookId: 1, 22 | articleCount: 1, 23 | articleReadCount: 1, 24 | articleSubscribeCount: 1, 25 | articleWordCount: 1, 26 | audioCommentedCount: 1, 27 | audioCount: 1, 28 | audioLikedCount: 1, 29 | audioListenCount: 1, 30 | authorTotalReadCount: 1, 31 | bookReviewCount: 1, 32 | booklistCollectCount: 1, 33 | booklistCount: 1, 34 | buyCount: 1, 35 | canExchange: 1, 36 | canExchangeDay: 1, 37 | continueReadDays: 1, 38 | curMonthReadTime: 1, 39 | followerCount: 1, 40 | followerListCount: 1, 41 | followingCount: 1, 42 | followingListCount: 1, 43 | gameInfo: 1, 44 | gender: 1, 45 | likeBookCount: 1, 46 | location: 1, 47 | medalInfo: 1, 48 | noteBookCount: 1, 49 | readCount: 1, 50 | readHistory: 1, 51 | readingTeam: 1, 52 | reviewCommentedCount: 1, 53 | reviewCount: 1, 54 | reviewLikedCount: 1, 55 | signature: 1, 56 | totalLikedCount: 1, 57 | totalNoteCount: 1, 58 | totalReadingTime: 1, 59 | unfinishOrderCount: 1, 60 | vDesc: 1, 61 | }, { 62 | vid: vid.toString(), 63 | skey: skey, 64 | "User-Agent": UserAgentForApp, 65 | v: '7.4.2.23' 66 | }) 67 | return resp.json() 68 | } 69 | 70 | export async function device_sessionlist(deviceId: string, vid: number | string, skey: string) { 71 | const resp = await get("https://i.weread.qq.com/device/sessionlist", { 72 | deviceId: deviceId, 73 | onlyCnt: "1", 74 | }, { 75 | vid: vid.toString(), 76 | skey: skey, 77 | "User-Agent": UserAgentForApp, 78 | v: '7.4.2.23' 79 | }) 80 | return resp.json() 81 | } 82 | 83 | export async function device_sessionremove(currentDeviceId: string, removeDeviceIds: string[], vid: number | string, skey: string) { 84 | const resp = await postJSON("https://i.weread.qq.com/device/sessionremove", { 85 | removeDeviceIds: removeDeviceIds, 86 | currentDeviceId: currentDeviceId 87 | }, { 88 | vid: vid.toString(), 89 | skey: skey, 90 | "User-Agent": UserAgentForApp, 91 | v: '7.4.2.23' 92 | }) 93 | return resp.json() 94 | } 95 | 96 | export async function phoneCheck(phone: string) { 97 | const resp = await get("http://i.weread.qq.com/phoneCheck", { 98 | tel: phone, 99 | }, { 100 | 101 | }) 102 | return resp.json() 103 | } 104 | -------------------------------------------------------------------------------- /src/apis/app/weekly.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | 3 | import {postJSON} from "../../utils/request.ts"; 4 | import {UserAgentForApp} from "../../config.ts"; 5 | 6 | const platform = "weread_wx-2001-iap-2001-iphone" 7 | 8 | /** 9 | * 查询所有可兑换列表 10 | * @param vid 11 | * @param skey 12 | */ 13 | export async function queryAllAwards(vid: string | number, skey: string) { 14 | const resp = await postJSON("https://i.weread.qq.com/weekly/exchange", { 15 | awardLevelId: 0, 16 | awardChoiceType: 0, 17 | isExchangeAward: 0, 18 | pf: platform, 19 | }, { 20 | vid: vid.toString(), 21 | skey: skey, 22 | "User-Agent": UserAgentForApp, 23 | }) 24 | return resp.json() 25 | } 26 | 27 | function awardFilter(data: any) { 28 | const awards = [] 29 | awards.push( 30 | ...data.readtimeAwards.map((item: any) => ({ 31 | id: item.awardLevelId, 32 | status: item.awardStatus, 33 | statusDesc: item.awardStatusDesc, 34 | name: `${item.awardLevelDesc}${item.awardChoicesDesc}`.replace(/\s/g, ''), 35 | })) 36 | ) 37 | awards.push( 38 | ...data.readdayAwards.map((item: any) => ({ 39 | id: item.awardLevelId, 40 | status: item.awardStatus, 41 | statusDesc: item.awardStatusDesc, 42 | name: `${item.awardLevelDesc}${item.awardChoicesDesc}`.replace(/\s/g, ''), 43 | })) 44 | ) 45 | return awards 46 | } 47 | 48 | /** 49 | * 兑换体验卡 50 | * @param id 51 | * @param vid 52 | * @param skey 53 | */ 54 | async function exchangeAward(id: number | string, vid: string | number, skey: string) { 55 | const resp = await postJSON("https://i.weread.qq.com/weekly/exchange", { 56 | awardLevelId: id, 57 | awardChoiceType: 1, // 免费账户只能兑换体验卡 58 | isExchangeAward: 1, 59 | pf: platform, 60 | }, { 61 | vid: vid.toString(), 62 | skey: skey, 63 | "User-Agent": UserAgentForApp, 64 | }) 65 | return resp.json() 66 | } 67 | 68 | /** 69 | * 兑换所有可兑换的体验卡 70 | */ 71 | export async function exchangeAllAward(vid: string | number, skey: string) { 72 | const resp = await queryAllAwards(vid, skey) 73 | if (resp.errcode) { 74 | // 接口出错,可能是skey过期 75 | return resp 76 | } 77 | 78 | const arards = awardFilter(resp) 79 | 80 | for (const award of arards.filter((_: any) => _.status === 1)) { 81 | const resp = await exchangeAward(award.id, vid, skey) 82 | if (resp.errcode) { 83 | // 接口出错,可能是skey过期 84 | return resp 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/apis/err-code.ts: -------------------------------------------------------------------------------- 1 | export enum ErrCode { 2 | /** 3 | * 用户不存在 4 | */ 5 | UserNotExist = -2010, 6 | 7 | /** 8 | * 会话超时 9 | */ 10 | SessionTimeout = -2012, 11 | 12 | /** 13 | * 鉴权失败 14 | */ 15 | AuthenticationFailed = -2013, 16 | 17 | /** 18 | * 微信授权已过期,需重新登录 19 | */ 20 | AuthenticationTimeout = -12013, 21 | 22 | /** 23 | * 无权限下载 24 | */ 25 | NoPermissionDownload = -2038, 26 | } 27 | -------------------------------------------------------------------------------- /src/apis/web/book.ts: -------------------------------------------------------------------------------- 1 | import {get, postJSON} from "../../utils/request.ts"; 2 | import {calcHash, currentTime, getAppId, sign, timestamp} from "../../utils/index.ts"; 3 | import {UserAgentForWeb} from "../../config.ts"; 4 | import {chk, dH, dS, dT} from "../../utils/decrypt.ts"; 5 | import styleParser from "../../utils/style.ts"; 6 | import htmlParser from "../../utils/html.ts"; 7 | import {processHtmls, processStyles} from "../../utils/process.ts"; 8 | import {sha256} from "../../utils/encode.ts"; 9 | 10 | /** 11 | * 获取图书详情 12 | * @param bookId 13 | * @param cookie 14 | */ 15 | export async function web_book_info(bookId: string, cookie = "") { 16 | const resp = await get("https://weread.qq.com/web/book/info", { 17 | bookId: bookId, 18 | }, { 19 | cookie: cookie, 20 | }); 21 | return resp.json(); 22 | } 23 | 24 | /** 25 | * 获取图书详情 26 | * 不需要登录 27 | */ 28 | export async function web_book_publicinfos(bookIds: string[]) { 29 | const resp = await postJSON("https://weread.qq.com/web/book/publicinfos", { 30 | bookIds, 31 | }) 32 | try { 33 | return resp.json() 34 | } catch (e) { 35 | console.log(e) 36 | console.log(resp) 37 | throw e 38 | } 39 | } 40 | 41 | export async function web_book_search(cookie = "") { 42 | const resp = await get("https://weread.qq.com/web/book/search", {}, {cookie}) 43 | return resp.json() 44 | } 45 | 46 | /** 47 | * 获取图书的章节信息 48 | * @param bookIds 49 | * @param cookie 50 | */ 51 | export async function web_book_chapterInfos(bookIds: string[], cookie = "") { 52 | const resp = await postJSON("https://weread.qq.com/web/book/chapterInfos", { 53 | bookIds, 54 | }, { 55 | cookie: cookie, 56 | }); 57 | return resp.json(); 58 | } 59 | 60 | /** 61 | * 获取进度信息 62 | * @param bookId 63 | * @param cookie 64 | */ 65 | export async function web_book_getProgress(bookId: string, cookie = "") { 66 | const resp = await get("https://weread.qq.com/web/book/getProgress", { 67 | bookId, 68 | }, { 69 | cookie: cookie, 70 | }); 71 | return resp.json(); 72 | } 73 | 74 | /** 75 | * 开始阅读 76 | * @param bookId 77 | * @param chapterUid 78 | * @param percent 79 | * @param chapterOffset 80 | * @param pc 81 | * @param ps 82 | * @param format 83 | * @param cookie 84 | */ 85 | export async function web_book_read_init( 86 | bookId: string, 87 | chapterUid: number, 88 | percent = 0, 89 | chapterOffset = 0, 90 | pc: number, 91 | ps: number, 92 | format = "epub", 93 | cookie = "", 94 | ) { 95 | const payload: Record = { 96 | "appId": getAppId(UserAgentForWeb), 97 | "b": calcHash(bookId), 98 | "c": calcHash(chapterUid || 0), 99 | "ci": chapterUid || 0, 100 | "co": chapterOffset, 101 | "ct": currentTime(), 102 | "dy": 0, 103 | "fm": format, 104 | "pc": calcHash(pc), 105 | "pr": percent, 106 | "ps": calcHash(ps), 107 | "sm": "", 108 | } 109 | payload.s = sign(payload) 110 | 111 | const resp = await postJSON("https://weread.qq.com/web/book/read", payload, { 112 | cookie: cookie, 113 | }); 114 | return resp.json() 115 | } 116 | 117 | /** 118 | * 上传进度 119 | * @param bookId 120 | * @param chapterUid 121 | * @param percent 122 | * @param chapterOffset 123 | * @param pc 124 | * @param ps 125 | * @param format 126 | * @param readerToken 127 | * @param cookie 128 | * @param rt 129 | */ 130 | export async function web_book_read( 131 | bookId: string, 132 | chapterUid: number, 133 | percent = 0, 134 | chapterOffset = 0, 135 | pc: number, 136 | ps: number, 137 | format = "epub", 138 | readerToken = "", 139 | cookie = "", 140 | rt = 60, 141 | ) { 142 | const ts = timestamp() 143 | const rnd = Math.floor(1000 * Math.random()) 144 | 145 | const payload: Record = { 146 | "appId": getAppId(UserAgentForWeb), 147 | "b": calcHash(bookId), 148 | "c": calcHash(chapterUid || 0), 149 | "ci": chapterUid || 0, 150 | "co": chapterOffset, 151 | "ct": currentTime(), 152 | "dy": 0, 153 | "fm": format, 154 | "pc": calcHash(pc), 155 | "pr": percent, 156 | "ps": calcHash(ps), 157 | "sm": "", 158 | rt: rt, // 最大只能为 60 159 | ts: ts, 160 | rn: rnd, 161 | sg: sha256("" + ts + rnd + readerToken), 162 | } 163 | payload.s = sign(payload) 164 | 165 | const resp = await postJSON("https://weread.qq.com/web/book/read", payload, { 166 | cookie: cookie, 167 | }); 168 | return resp.json() 169 | } 170 | 171 | export async function web_book_bookmarklist(bookId: string, cookie = "") { 172 | const resp = await get("https://weread.qq.com/web/book/bookmarklist", { 173 | bookId: bookId, 174 | }, { 175 | cookie: cookie, 176 | }); 177 | return resp.json(); 178 | } 179 | 180 | export async function web_book_chapter_e0( 181 | bookId: string, 182 | chapterUid: number, 183 | cookie = "", 184 | ) { 185 | const payload: Record = { 186 | "b": calcHash(bookId), 187 | "c": calcHash(chapterUid), 188 | "r": Math.pow(Math.floor(10_000 * Math.random()), 2), 189 | "st": 0, 190 | "ct": currentTime(), 191 | "ps": "a2b325707a19e580g0186a2", 192 | "pc": "430321207a19e581g013ab0", 193 | }; 194 | payload.s = sign(payload); 195 | 196 | const resp = await postJSON( 197 | "https://weread.qq.com/web/book/chapter/e_0", 198 | payload, 199 | { 200 | cookie: cookie, 201 | }, 202 | ); 203 | const data = await resp.text(); 204 | return data && "string" === typeof data ? chk(data) : ""; 205 | } 206 | 207 | export async function web_book_chapter_e1( 208 | bookId: string, 209 | chapterUid: number, 210 | cookie = "", 211 | ) { 212 | const payload: Record = { 213 | "b": calcHash(bookId), 214 | "c": calcHash(chapterUid), 215 | "r": Math.pow(Math.floor(10_000 * Math.random()), 2), 216 | "st": 0, 217 | "ct": currentTime(), 218 | "ps": "a2b325707a19e580g0186a2", 219 | "pc": "430321207a19e581g013ab0", 220 | }; 221 | payload.s = sign(payload); 222 | 223 | const resp = await postJSON( 224 | "https://weread.qq.com/web/book/chapter/e_1", 225 | payload, 226 | { 227 | cookie: cookie, 228 | }, 229 | ); 230 | const data = await resp.text(); 231 | return data && "string" === typeof data ? chk(data) : ""; 232 | } 233 | 234 | export async function web_book_chapter_e2( 235 | bookId: string, 236 | chapterUid: number, 237 | cookie = "", 238 | ) { 239 | const payload: Record = { 240 | "b": calcHash(bookId), 241 | "c": calcHash(chapterUid), 242 | "r": Math.pow(Math.floor(10_000 * Math.random()), 2), 243 | "st": 1, 244 | "ct": currentTime(), 245 | "ps": "a2b325707a19e580g0186a2", 246 | "pc": "430321207a19e581g013ab0", 247 | }; 248 | payload.s = sign(payload); 249 | 250 | const resp = await postJSON( 251 | "https://weread.qq.com/web/book/chapter/e_2", 252 | payload, 253 | { 254 | cookie: cookie, 255 | }, 256 | ); 257 | const data = await resp.text(); 258 | return data && "string" === typeof data ? chk(data) : ""; 259 | } 260 | 261 | export async function web_book_chapter_e3( 262 | bookId: string, 263 | chapterUid: number, 264 | cookie = "", 265 | ) { 266 | const payload: Record = { 267 | "b": calcHash(bookId), 268 | "c": calcHash(chapterUid), 269 | "r": Math.pow(Math.floor(10_000 * Math.random()), 2), 270 | "st": 0, 271 | "ct": currentTime(), 272 | "ps": "a2b325707a19e580g0186a2", 273 | "pc": "430321207a19e581g013ab0", 274 | }; 275 | payload.s = sign(payload); 276 | 277 | const resp = await postJSON( 278 | "https://weread.qq.com/web/book/chapter/e_3", 279 | payload, 280 | { 281 | cookie: cookie, 282 | }, 283 | ); 284 | const data = await resp.text(); 285 | return data && "string" === typeof data ? chk(data) : ""; 286 | } 287 | 288 | export async function web_book_chapter_t0( 289 | bookId: string, 290 | chapterUid: number, 291 | cookie = "", 292 | ) { 293 | const payload: Record = { 294 | "b": calcHash(bookId), 295 | "c": calcHash(chapterUid), 296 | "r": Math.pow(Math.floor(10_000 * Math.random()), 2), 297 | "st": 0, 298 | "ct": currentTime(), 299 | "ps": "a2b325707a19e580g0186a2", 300 | "pc": "430321207a19e581g013ab0", 301 | }; 302 | payload.s = sign(payload); 303 | 304 | const resp = await postJSON( 305 | "https://weread.qq.com/web/book/chapter/t_0", 306 | payload, 307 | { 308 | cookie: cookie, 309 | }, 310 | ); 311 | const data = await resp.text(); 312 | return data && "string" === typeof data ? chk(data) : ""; 313 | } 314 | 315 | export async function web_book_chapter_t1( 316 | bookId: string, 317 | chapterUid: number, 318 | cookie = "", 319 | ) { 320 | const payload: Record = { 321 | "b": calcHash(bookId), 322 | "c": calcHash(chapterUid), 323 | "r": Math.pow(Math.floor(10_000 * Math.random()), 2), 324 | "st": 1, 325 | "ct": currentTime(), 326 | "ps": "a2b325707a19e580g0186a2", 327 | "pc": "430321207a19e581g013ab0", 328 | }; 329 | payload.s = sign(payload); 330 | 331 | const resp = await postJSON( 332 | "https://weread.qq.com/web/book/chapter/t_1", 333 | payload, 334 | { 335 | cookie: cookie, 336 | }, 337 | ); 338 | const data = await resp.text(); 339 | return data && "string" === typeof data ? chk(data) : ""; 340 | } 341 | 342 | /** 343 | * 获取章节内容 344 | * @param bookId 345 | * @param chapterUid 346 | * @param cookie 347 | */ 348 | export async function web_book_chapter_e( 349 | bookId: string, 350 | chapterUid: number, 351 | cookie = "", 352 | ): Promise { 353 | let promise: Promise<[string[], string | null]>; 354 | const { format } = await web_book_info(bookId, cookie); 355 | if (format === "epub" || format === "pdf") { 356 | promise = Promise.all([ 357 | web_book_chapter_e0(bookId, chapterUid, cookie), 358 | web_book_chapter_e1(bookId, chapterUid, cookie), 359 | web_book_chapter_e2(bookId, chapterUid, cookie), 360 | web_book_chapter_e3(bookId, chapterUid, cookie), 361 | ]).then((results) => { 362 | if ( 363 | "string" == typeof results[0] && results[0].length > 0 && 364 | "string" == typeof results[1] && results[1].length > 0 && 365 | "string" == typeof results[3] && results[3].length > 0 366 | ) { 367 | let styles = dS(results[2]); 368 | styles = styleParser.parse(styles, { 369 | removeFontSizes: true, 370 | enableTranslate: false, 371 | }); 372 | 373 | const html = dH(results[0] + results[1] + results[3]); 374 | const htmls = htmlParser.parse(html, styles, 10000); 375 | return [htmls, styles]; 376 | } else { 377 | console.log(results); 378 | throw Error(`下载失败(${bookId})`); 379 | } 380 | }); 381 | } else if (format === "txt") { 382 | promise = Promise.all([ 383 | web_book_chapter_t0(bookId, chapterUid, cookie), 384 | web_book_chapter_t1(bookId, chapterUid, cookie), 385 | ]).then((results) => { 386 | if ( 387 | "string" === typeof results[0] && results[0].length > 0 && 388 | "string" == typeof results[1] && results[1].length > 0 389 | ) { 390 | const html = dT(results[0] + results[1]); 391 | const htmls = htmlParser.parseTxt(html, 10000); 392 | return [htmls, null]; 393 | } else { 394 | console.log(results); 395 | throw Error("下载失败"); 396 | } 397 | }); 398 | } else { 399 | throw Error(`暂不支持${format}格式(${bookId})`); 400 | } 401 | 402 | let [htmls, styles] = await promise; 403 | 404 | // 处理style 405 | if (styles) { 406 | styles = processStyles(styles, bookId); 407 | } 408 | 409 | // 处理html 410 | htmls = processHtmls(htmls, bookId); 411 | 412 | // 对 html 进行一些处理 413 | const sections = htmls.map((html) => { 414 | // 图片的处理 415 | // 去掉 base64 图片地址(该图片是占位符) 416 | html = html.replaceAll(/(]+?)(src="data:[^"]+")/gs, "$1"); 417 | // 将 data-src 替换成 src 418 | html = html.replaceAll(/(]+?)data-src="/gs, '$1src="'); 419 | 420 | // 剥离body外壳 421 | const bodyRe = /^<\/head>(?.*)<\/body><\/html>$/s; 422 | const match = html.match(bodyRe); 423 | if (match) { 424 | return match.groups!.body; 425 | } 426 | return html; 427 | }).join(""); 428 | 429 | return ` 430 |
431 | 432 | ${sections} 433 |
434 | `; 435 | } 436 | -------------------------------------------------------------------------------- /src/apis/web/category.ts: -------------------------------------------------------------------------------- 1 | import {get} from "../../utils/request.ts"; 2 | 3 | /** 4 | * 图书分类 5 | * 纯数字的为大类,下面还分小类,从1开始累加 6 | */ 7 | export type BookCategory = 8 | | "rising" // 飙升榜 9 | | "newbook" // 新书榜 10 | | "general_novel_rising" // 小说榜 11 | | "all" // 总榜 12 | | "newrating_publish" // 神作榜 13 | | "newrating_potential_publish" // 潜力榜 14 | | "hot_search" // 热搜榜 15 | | "100000" // 精品小说 16 | | "200000" // 历史 17 | | "300000" // 文学 18 | | "400000" // 艺术 19 | | "500000" // 人物传记 20 | | "600000" // 哲学宗教 21 | | "700000" // 计算机 22 | | "800000" // 心理 23 | | "900000" // 社会文化 24 | | "1000000" // 个人成长 25 | | "1100000" // 经济理财 26 | | "1200000" // 政治军事 27 | | "1300000" // 童书 28 | | "1400000" // 教育学习 29 | | "1500000" // 科学技术 30 | | "1600000" // 生活百科 31 | | "1700000" // 期刊杂志 32 | | "1800000" // 原版书 33 | | "2100000" // 医学健康 34 | | "1900000" // 男生小说 35 | | "2000000"; // 女生小说 36 | 37 | /** 38 | * 获取指定分类下面的图书列表 39 | * @param categoryId 分类 40 | * @param startAt 起始排名(从1开始) 41 | * @param cookie 42 | * @description 每次返回20条数据 43 | */ 44 | export async function bookListInCategory( 45 | categoryId: string, 46 | startAt = 1, 47 | ) { 48 | const query: Record = { 49 | maxIndex: startAt - 1, 50 | rank: 1, 51 | }; 52 | if (/^\d+$/.test(categoryId)) { 53 | delete query.rank; 54 | } 55 | const resp = await get( 56 | `https://weread.qq.com/web/bookListInCategory/${categoryId}`, 57 | query, 58 | ); 59 | return resp.json(); 60 | } 61 | 62 | /** 63 | * 查询分类详情 64 | * @param categoryId 分类id 65 | * @param cookie 66 | */ 67 | export async function categoryinfo(categoryId: BookCategory, cookie = "") { 68 | let rank = 1; 69 | if (/^\d+$/.test(categoryId)) { 70 | rank = 0; 71 | } 72 | const resp = await get("https://weread.qq.com/web/categoryinfo", { 73 | rank: rank, 74 | categoryId: categoryId, 75 | }, { 76 | cookie: cookie, 77 | }); 78 | return resp.json(); 79 | } 80 | 81 | /** 82 | * 查询分类数据 83 | */ 84 | export async function categories() { 85 | const resp = await get("https://weread.qq.com/web/categories", {synckey: 0}); 86 | return resp.json(); 87 | } 88 | 89 | /** 90 | * 获取推荐书籍 91 | */ 92 | export async function recommendBooks(cookie = "") { 93 | const resp = await get("https://weread.qq.com/web/recommend_books", {}, { 94 | cookie: cookie, 95 | }); 96 | return resp.json(); 97 | } 98 | 99 | interface Category { 100 | CategoryId: string 101 | sublist: Category[] 102 | } 103 | 104 | /** 105 | * 获取所有的分类id 106 | */ 107 | export async function getAllCategoryId() { 108 | const categoryIds: string[] = [] 109 | const resp = await categories() 110 | const allCategories = resp.data.flatMap((_: any) => { 111 | if (_.categories && Array.isArray(_.categories)) { 112 | return _.categories 113 | } else { 114 | return _ 115 | } 116 | }) 117 | collectCategoryId(allCategories, categoryIds) 118 | return categoryIds 119 | } 120 | 121 | function collectCategoryId(categories: Category[], result: string[]) { 122 | for (const category of categories) { 123 | if (category.CategoryId) { 124 | result.push(category.CategoryId) 125 | } 126 | if (category.sublist && category.sublist.length > 0) { 127 | collectCategoryId(category.sublist, result) 128 | } 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/apis/web/login.ts: -------------------------------------------------------------------------------- 1 | import {get, postJSON} from "../../utils/request.ts"; 2 | 3 | /** 4 | * 获取uid 5 | */ 6 | export async function web_login_getuid() { 7 | const resp = await postJSON("https://weread.qq.com/web/login/getuid"); 8 | return resp.json(); 9 | } 10 | 11 | /** 12 | * 生成登录链接 13 | * @param uid 14 | */ 15 | export function web_confirm(uid: string) { 16 | return `https://weread.qq.com/web/confirm?pf=2&uid=${uid}`; 17 | } 18 | 19 | /** 20 | * 查询用户扫码信息 21 | * @param uid 22 | */ 23 | export async function web_login_getinfo(uid: string) { 24 | const resp = await postJSON("https://weread.qq.com/web/login/getinfo", { 25 | uid, 26 | }); 27 | return resp.json(); 28 | } 29 | 30 | /** 31 | * 使用扫码信息进行登录 32 | * @param info 33 | */ 34 | export async function web_login_weblogin(info: Record = {}) { 35 | delete info.redirect_uri; 36 | delete info.expireMode; 37 | delete info.pf; 38 | 39 | info.fp = ""; 40 | const resp = await postJSON("https://weread.qq.com/web/login/weblogin", info); 41 | return resp.json(); 42 | } 43 | 44 | /** 45 | * 初始化会话 46 | * @param info 47 | */ 48 | export async function web_login_session_init(info: Record = {}) { 49 | const params = { 50 | vid: info.vid, 51 | pf: 0, 52 | skey: info.accessToken, 53 | rt: info.refreshToken, 54 | }; 55 | const resp = await postJSON( 56 | "https://weread.qq.com/web/login/session/init", 57 | params, 58 | ); 59 | return resp.json(); 60 | } 61 | 62 | /** 63 | * 刷新skey 64 | * @param url 原请求路径 65 | * @param cookie 66 | */ 67 | export async function web_login_renewal(url: string, cookie = "") { 68 | const resp = await postJSON("https://weread.qq.com/web/login/renewal", { 69 | rq: encodeURIComponent(url), 70 | }, { 71 | cookie, 72 | }); 73 | 74 | const data = await resp.json(); 75 | if (data.succ === 1) { 76 | return resp.headers.getSetCookie().reduce( 77 | (entry: Record, cookie) => { 78 | const item = cookie.split(";")[0]; 79 | const [name, value] = item.split("="); 80 | if (name === "wr_vid") { 81 | entry.vid = value; 82 | } else if (name === "wr_skey") { 83 | entry.accessToken = value; 84 | } else if (name === "wr_rt") { 85 | entry.refreshToken = value; 86 | } 87 | return entry; 88 | }, 89 | {}, 90 | ); 91 | } else { 92 | // { errCode: -12013, errMsg: "微信登录授权已过期,继续购买需跳转到微信重新登录" } 93 | // { errCode: -2013, errLog: "C6LyBKI", errMsg: "鉴权失败" } 94 | if (data.errCode !== -12013) { 95 | console.warn('/web/login/renewal接口失败', data, cookie) 96 | } 97 | throw Error(data.errMsg); 98 | } 99 | } 100 | 101 | /** 102 | * 通知后台前端已登录 103 | */ 104 | export async function web_login_notify(cookie = "") { 105 | const resp = await get("https://weread.qq.com/web/login/notify", {}, {cookie}) 106 | return resp.json() 107 | } 108 | -------------------------------------------------------------------------------- /src/apis/web/misc.ts: -------------------------------------------------------------------------------- 1 | import {get} from "../../utils/request.ts"; 2 | 3 | /** 4 | * 获取pdf书籍的下载地址 5 | * @param bookId 6 | * @param cookie 7 | */ 8 | export async function getPDFUrl(bookId: string, cookie = "") { 9 | const resp = await get("https://res.weread.qq.com/cos/download", { 10 | getUrl: '1', 11 | bookId, 12 | }, { 13 | cookie, 14 | }) 15 | return resp.json() 16 | } 17 | 18 | /** 19 | * 获取reader token,上传进度时需要 20 | * @param cookie 21 | */ 22 | export async function getConfig(cookie = "") { 23 | const resp = await get("https://weread.qq.com/web/getConfig", {}, {cookie}) 24 | return resp.json() 25 | } 26 | 27 | /** 28 | * 通知后台pdf转epub 29 | * @param bookId 30 | * @param cookie 31 | */ 32 | export async function pdf2epub(bookId: string, cookie = "") { 33 | const resp = await get("https://weread.qq.com/web/pdf2epub/notify", { 34 | cbid: bookId, 35 | }, {cookie}) 36 | return resp.json() 37 | } 38 | -------------------------------------------------------------------------------- /src/apis/web/pay.ts: -------------------------------------------------------------------------------- 1 | import {get, postJSON} from "../../utils/request.ts"; 2 | 3 | /** 4 | * 获取账户余额 5 | * @param pf 平台 6 | * @param cookie 7 | */ 8 | export async function web_pay_balance(pf = "ios", cookie = "") { 9 | const resp = await postJSON("https://weread.qq.com/web/pay/balance", { 10 | pf, 11 | }, { 12 | cookie: cookie, 13 | }); 14 | return resp.json(); 15 | } 16 | 17 | /** 18 | * 获取账户会员卡信息 19 | * @param pf 平台 20 | * @param cookie 21 | */ 22 | export async function web_pay_memberCardSummary(pf = "ios", cookie = "") { 23 | const resp = await get("https://weread.qq.com/web/pay/memberCardSummary", { 24 | pf, 25 | }, { 26 | cookie: cookie, 27 | }); 28 | return resp.json(); 29 | } 30 | -------------------------------------------------------------------------------- /src/apis/web/review.ts: -------------------------------------------------------------------------------- 1 | import {get, postJSON} from "../../utils/request.ts"; 2 | 3 | // 评分 4 | export enum ReviewRatingLevel { 5 | /** 6 | * 推荐 7 | */ 8 | Good = 1, 9 | 10 | /** 11 | * 一般 12 | */ 13 | Normal = 2, 14 | 15 | /** 16 | * 不行 17 | */ 18 | Bad = 3, 19 | } 20 | 21 | // 可访问性 22 | export enum ReviewAccessibility { 23 | /** 24 | * 私密,仅自己可见 25 | */ 26 | Private, 27 | 28 | /** 29 | * 关注,仅互相关注可见 30 | */ 31 | Friendship, 32 | 33 | /** 34 | * 公开,所有人可见 35 | */ 36 | Public 37 | } 38 | 39 | 40 | /** 41 | * 获取自己的评价 42 | * @description 获取之前需要先获取正确的synckey,只有正确的 synckey 才能进行正确的分页 43 | * @param bookId 44 | * @param startIdx 起始索引(从0开始) 45 | * @param count 数量 46 | * @param synckey 47 | * @param cookie 48 | */ 49 | export async function web_review_list_myself(bookId: string, startIdx = 0, count = 20, synckey = 0, cookie = "") { 50 | const resp = await get("https://weread.qq.com/web/review/list", { 51 | bookId: bookId, 52 | listType: 4, // todo: 11也可以获取自己的评价,目前不清楚他们的区别 53 | maxIdx: startIdx, 54 | count: count, 55 | listMode: 2, 56 | synckey: synckey, 57 | }, {cookie}) 58 | return resp.json() 59 | } 60 | 61 | /** 62 | * 获取书评的 synckey 63 | * @param bookId 64 | */ 65 | export async function get_review_synckey(bookId: string) { 66 | return (await web_review_list(bookId, 0, 0, 0)).synckey 67 | } 68 | 69 | /** 70 | * 获取书籍的评价 71 | * @description 获取之前需要先获取正确的synckey,只有正确的 synckey 才能进行正确的分页 72 | * @param bookId 73 | * @param startIdx 起始索引(从0开始) 74 | * @param count 数量 75 | * @param synckey 76 | */ 77 | export async function web_review_list(bookId: string, startIdx = 0, count = 20, synckey = 0) { 78 | const resp = await get("https://weread.qq.com/web/review/list", { 79 | bookId: bookId, 80 | listType: 3, 81 | maxIdx: startIdx, 82 | count: count, 83 | listMode: 2, 84 | synckey: synckey, 85 | }) 86 | return resp.json() 87 | } 88 | 89 | 90 | export async function web_review_single(reviewId: string, cookie = "") { 91 | const resp = await get("https://weread.qq.com/web/review/single", { 92 | reviewId, 93 | }, { 94 | cookie, 95 | }) 96 | return resp.json() 97 | } 98 | 99 | 100 | /** 101 | * 添加评价 102 | * @param bookId 103 | * @param ratingLevel 104 | * @param accessibility 105 | * @param content 106 | * @param cookie 107 | */ 108 | export async function web_review_add( 109 | bookId: string, 110 | ratingLevel: ReviewRatingLevel, 111 | accessibility: ReviewAccessibility, 112 | content: string, 113 | cookie = "" 114 | ) { 115 | const payload: Record = { 116 | bookId: bookId, 117 | content: content, 118 | newRatingLevel: ratingLevel, 119 | type: 4, 120 | } 121 | if (accessibility === ReviewAccessibility.Private) { 122 | payload.isPrivate = 1 123 | } else if (accessibility === ReviewAccessibility.Friendship) { 124 | payload.friendship = 1 125 | } 126 | 127 | const resp = await postJSON("https://weread.qq.com/web/review/add", payload, {cookie}) 128 | return resp.json() 129 | } 130 | -------------------------------------------------------------------------------- /src/apis/web/shelf.ts: -------------------------------------------------------------------------------- 1 | import { get, postJSON } from "../../utils/request.ts"; 2 | 3 | /** 4 | * 获取书架上的书 5 | * @param query 可以提供额外参数来控制结果,比如 6 | * onlyBookid: 1 只返回bookId 7 | * cbcount: 1 返回上传的图书数量 8 | * @param cookie 9 | */ 10 | export async function web_shelf_sync( 11 | query: Record = {}, 12 | cookie = "", 13 | ) { 14 | const resp = await get("https://weread.qq.com/web/shelf/sync", query, { 15 | cookie: cookie, 16 | }); 17 | return resp.json(); 18 | } 19 | 20 | /** 21 | * 获取迷你书架数据 22 | * @param bookIds 23 | * @param cookie 24 | */ 25 | export async function web_shelf_syncBook(bookIds: string[] = [], cookie = "") { 26 | const resp = await postJSON("https://weread.qq.com/web/shelf/syncBook", { 27 | bookIds, 28 | }, { 29 | cookie: cookie, 30 | }); 31 | return resp.json(); 32 | } 33 | 34 | /** 35 | * 批量查询图书是否在书架上 36 | * @param bookIds 37 | * @param cookie 38 | */ 39 | export async function web_shelf_bookIds(bookIds: string[] = [], cookie = "") { 40 | const resp = await get("https://weread.qq.com/web/shelf/bookIds", { 41 | bookIds: bookIds.join(','), 42 | }, { 43 | cookie: cookie, 44 | }) 45 | return resp.json() 46 | } 47 | 48 | /** 49 | * 添加书籍到书架 50 | * @param bookIds 51 | * @param cookie 52 | */ 53 | export async function web_shelf_addToShelf(bookIds: string[] = [], cookie = "") { 54 | const resp = await postJSON("https://weread.qq.com/mp/shelf/addToShelf", { 55 | bookIds: bookIds, 56 | }, { 57 | cookie: cookie, 58 | }) 59 | return resp.json() 60 | } 61 | 62 | /** 63 | * 添加书籍到书架 64 | * @param bookIds 65 | * @param cookie 66 | */ 67 | export async function web_shelf_add(bookIds: string[] = [], cookie = "") { 68 | const resp = await postJSON("https://weread.qq.com/web/shelf/add", { 69 | bookIds, 70 | }, { 71 | cookie, 72 | }) 73 | return resp.json() 74 | } 75 | -------------------------------------------------------------------------------- /src/apis/web/upload.ts: -------------------------------------------------------------------------------- 1 | import { get } from "../../utils/request.ts"; 2 | 3 | /** 4 | * 查询书架是否已满 5 | */ 6 | export async function mp_shelf_shelfFull(cookie = "") { 7 | const resp = await get("https://weread.qq.com/mp/shelf/shelfFull", {}, { 8 | cookie: cookie, 9 | }); 10 | return resp.json(); 11 | } 12 | -------------------------------------------------------------------------------- /src/apis/web/user.ts: -------------------------------------------------------------------------------- 1 | import { get } from "../../utils/request.ts"; 2 | 3 | /** 4 | * 查询用户信息 5 | * @param vid 6 | * @param cookie 7 | */ 8 | export async function web_user(vid: number | string, cookie = "") { 9 | const resp = await get("https://weread.qq.com/web/user", { 10 | userVid: vid, 11 | }, { 12 | cookie: cookie, 13 | }); 14 | return resp.json(); 15 | } 16 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | export const UserAgentForWeb = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36"; 2 | 3 | export const UserAgentForApp = "WeRead/7.4.2 (iPhone; iOS 17.1; Scale/3.00)" 4 | 5 | // 每月下载限额 6 | export const MAX_DOWNLOAD_COUNT_PER_MONTH = 30; 7 | -------------------------------------------------------------------------------- /src/cron/common.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | 3 | import * as credentialUtil from "../kv/credential.ts"; 4 | import {Credential} from "../kv/credential.ts"; 5 | import {ApiCallResponse, ResponseCode} from "../frontend/apis/common.ts"; 6 | import {web_login_renewal} from "../apis/web/login.ts"; 7 | 8 | 9 | /** 10 | * 带有重试功能的 api 调用 11 | * @description 当使用 token 调用相关接口时,如果返回结果是 cookie 过期,则会自动调用 web_login_renewal 接口刷新 cookie 进行重试 12 | * @param apiCall api调用函数,如果不需要刷新token,但是想要重试时,可以返回 {needRepeat:true} 这个对象 13 | * @param token 内部会判断token是否已过期,并自动刷新 14 | * @param retry 重试次数,默认3次 15 | * @return api调用结果 16 | */ 17 | export async function executeApiCallWithRetry(apiCall: (credential: Credential) => Promise, token: string, retry = 3): Promise { 18 | async function executor(retry: number) { 19 | const credential = await credentialUtil.getByToken(token) 20 | if (!credential) { 21 | return {code: ResponseCode.ParamError, msg: `token(${token})未查询到用户信息`} 22 | } 23 | const cookie = credentialUtil.getCookieByCredential(credential) 24 | 25 | const resp = await apiCall(credential) 26 | if (resp && 27 | ( 28 | // -2010: 用户不存在 29 | // -2012: 登录超时 30 | // -2013: 鉴权失败 31 | [-2010, -2012, -2013].includes(resp.errCode) || 32 | [-2010, -2012, -2013].includes(resp.errcode) 33 | ) && 34 | retry > 0) { 35 | // skey过期,重新刷新 36 | // console.debug() 37 | try { 38 | const credentialInfo = await web_login_renewal("/web/shelf/sync", cookie); 39 | const {accessToken, refreshToken} = credentialInfo; 40 | credential.skey = accessToken 41 | credential.rt = refreshToken 42 | credential.updatedAt = Date.now() 43 | await credentialUtil.update(credential) 44 | 45 | // 重新调用原始接口 46 | return executor(--retry); 47 | } catch (e) { 48 | console.error(e); 49 | // 可能是鉴权失败,需要重新登录 50 | return {code: ResponseCode.CredentialError, msg: e.message} 51 | } 52 | } else if (resp && resp.needRepeat) { 53 | // 重新调用原始接口 54 | return executor(--retry); 55 | } 56 | 57 | return {code: ResponseCode.Success, data: resp, msg: 'success'} 58 | } 59 | 60 | return await executor(retry) 61 | } 62 | -------------------------------------------------------------------------------- /src/cron/exchange.ts: -------------------------------------------------------------------------------- 1 | import * as credentialUtil from "../kv/credential.ts"; 2 | import {exchangeAllAward} from "../apis/app/weekly.ts"; 3 | import {jsonResponse} from "../utils/index.ts"; 4 | import {ResponseCode} from "../frontend/apis/common.ts"; 5 | import {executeApiCallWithRetry} from "./common.ts"; 6 | import type {Credential} from "../kv/credential.ts"; 7 | 8 | 9 | /** 10 | * 执行兑换体验卡任务 11 | * 由外部 cron 触发(Cloudflare Worker) 12 | * todo: 等 deno 原生支持 cron 后,可以切换为 deno cron 13 | */ 14 | export async function runExchangeTask(_: Request) { 15 | console.debug('触发 cron::runExchangeTask 任务') 16 | 17 | // 从配置中读取有哪些用户需要兑换 18 | const users: number[] = [] 19 | 20 | for (const vid of users) { 21 | const token = await credentialUtil.getTokenByVid(vid) 22 | if (!token) { 23 | continue 24 | } 25 | 26 | const resp = await executeApiCallWithRetry(async (credential: Credential) => { 27 | return await exchangeAllAward(credential.vid, credential.skey) 28 | }, token) 29 | if (resp.code !== ResponseCode.Success) { 30 | // 重试失败 31 | } 32 | } 33 | 34 | return jsonResponse({code: ResponseCode.Success, msg: '兑换任务执行完成'}) 35 | } 36 | -------------------------------------------------------------------------------- /src/cron/read.ts: -------------------------------------------------------------------------------- 1 | import * as taskManager from "../kv/task.ts"; 2 | import * as credentialUtil from "../kv/credential.ts"; 3 | import type {Credential} from "../kv/credential.ts"; 4 | import {jsonResponse, randomInteger, sleep, formatSeconds} from "../utils/index.ts"; 5 | import {ResponseCode} from "../frontend/apis/common.ts"; 6 | import {web_book_read} from "../apis/web/book.ts"; 7 | import {friend_ranking} from "../apis/app/friend.ts"; 8 | import {ErrCode} from "../apis/err-code.ts"; 9 | import {web_login_renewal} from "../apis/web/login.ts"; 10 | 11 | 12 | /** 13 | * 执行自动阅读任务 14 | * 由外部的 cron 触发,每 **30分钟** 触发一次 15 | */ 16 | export async function runReadTask(_: Request) { 17 | console.debug('%c触发 cron::runReadTask 任务', 'color: green') 18 | const start = Date.now() 19 | 20 | const tasks = await taskManager.getAllReadingTask() 21 | const readerToken = await taskManager.getReaderToken() || '' 22 | 23 | for (const task of tasks) { 24 | const taskStartTime = Date.now() 25 | 26 | // 准备这个任务的相关参数 27 | const bookId = task.book.bookId 28 | const token = task.credential.token 29 | let credential = await credentialUtil.getByToken(token) 30 | 31 | // 先检查 cookie 是不是可能过期 32 | if (Date.now() - credential.updatedAt >= 1000 * (5400 - 30) /* 留30秒空隙 */) { 33 | // cookie 可能已经过期,尝试刷新 34 | const refreshCookieSuccess = await refreshCookie(task.credential) 35 | if (!refreshCookieSuccess) { 36 | // 刷新失败,就没必要执行下去了 37 | console.log(`cookie刷新失败,跳过任务(${task.credential.name}:${task.credential.vid}:${task.book.title})`) 38 | continue 39 | } 40 | // 刷新之后获取最新的 cookie 41 | credential = await credentialUtil.getByToken(token) 42 | } 43 | 44 | 45 | // 计算服务器及客户端加载的时间戳 46 | const pc = Math.floor(new Date('2023-10-09T15:10+08:00').getTime() / 1000) 47 | const ps = pc - randomInteger(2, 10) 48 | 49 | // 查询最新的阅读进度 50 | let latestSeconds = await getReadingTime(credential) 51 | if (latestSeconds === -1) { 52 | // 获取失败,跳过这个任务 53 | console.log(`获取进度失败,跳过任务(${task.credential.name}:${task.credential.vid}:${task.book.title})`) 54 | continue 55 | } 56 | 57 | let stop = false 58 | let totalSeconds = 0 59 | const readTime = 60 // 每次更新的阅读时长,单位为秒 60 | 61 | while (!stop) { 62 | const resp = await updateRead(bookId, pc, ps, readerToken, credential, readTime) 63 | if (resp.succ === 1) { 64 | // 更新进度成功,查询本次增加的阅读时长 65 | const seconds = await getReadingTime(credential) 66 | if (seconds === -1) { 67 | // 获取进度数据失败,结束本次任务 68 | stop = true 69 | } else { 70 | // 更新成功 71 | const delta = seconds - latestSeconds 72 | totalSeconds += delta 73 | latestSeconds = seconds 74 | 75 | if (delta !== readTime) { 76 | // 实际更新数值不等于发起的请求,循环结束 77 | stop = true 78 | } else { 79 | await sleep(2000) 80 | } 81 | } 82 | } else { 83 | // 更新进度失败 84 | stop = true 85 | } 86 | } 87 | 88 | console.log(`任务(${task.credential.name}:${task.credential.vid}:${task.book.title})成功更新: %c${formatSeconds(totalSeconds)}%c,耗时: ${((Date.now() - taskStartTime) / 1000).toFixed(1)}s`, 'color: green;font-weight: bold;', '') 89 | 90 | // 写入 91 | await taskManager.updateReadingTask(credential, totalSeconds) 92 | } 93 | 94 | console.log(`全部任务(${tasks.length})执行完毕,耗时: %c${((Date.now() - start) / 1000).toFixed(1)}s`, 'color: red; font-weight: bold;') 95 | return jsonResponse({code: ResponseCode.Success, msg: '阅读任务执行完成'}) 96 | } 97 | 98 | /** 99 | * 更新阅读进度 100 | */ 101 | async function updateRead(bookId: string, pc: number, ps: number, readerToken: string, credential: Credential, readTime: number) { 102 | const resp = await web_book_read( 103 | bookId, 104 | 2, 105 | 0, 106 | 0, 107 | pc, 108 | ps, 109 | "epub", 110 | readerToken, 111 | credentialUtil.getCookieByCredential(credential), 112 | readTime, 113 | ) 114 | if (resp.succ !== 1) { 115 | console.warn('更新阅读进度接口失败: ', resp, credential) 116 | 117 | // 如果出现cookie过期,则刷新cookie 118 | if (resp.errCode === ErrCode.SessionTimeout) { 119 | await refreshCookie(credential) 120 | } else { 121 | // 其他类型的错误暂不处理 122 | } 123 | } 124 | 125 | return resp 126 | } 127 | 128 | /** 129 | * 获取阅读时长(秒) 130 | */ 131 | async function getReadingTime(credential: Credential): Promise { 132 | const resp = await friend_ranking(credential.vid, credential.skey) 133 | if (resp && resp.ranking && Array.isArray(resp.ranking)) { 134 | // deno-lint-ignore no-explicit-any 135 | const targetRank = resp.ranking.find((_: any) => _.user.userVid === credential.vid) 136 | if (targetRank) { 137 | return targetRank.readingTime 138 | } else { 139 | console.warn(`没有找到目标用户排名数据(vid:${credential.vid}, name:${credential.name}, skey:${credential.skey})`) 140 | return -1 141 | } 142 | } else { 143 | console.warn('获取阅读时长接口失败: ', resp, credential) 144 | 145 | if (resp.errcode === ErrCode.SessionTimeout) { 146 | await refreshCookie(credential) 147 | } 148 | 149 | return -1 150 | } 151 | } 152 | 153 | /** 154 | * 刷新cookie 155 | * @param credential 156 | */ 157 | export async function refreshCookie(credential: Credential) { 158 | try { 159 | const oldCookie = credentialUtil.getCookieByCredential(credential) 160 | const credentialInfo = await web_login_renewal("/web/shelf/sync", oldCookie); 161 | const {accessToken, refreshToken} = credentialInfo; 162 | credential.skey = accessToken 163 | credential.rt = refreshToken 164 | credential.updatedAt = Date.now() 165 | await credentialUtil.update(credential) 166 | return true 167 | } catch (e) { 168 | if (e.message !== '微信登录授权已过期,继续购买需跳转到微信重新登录') { 169 | console.error(e); 170 | } 171 | return false 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /src/database/bookid.ts: -------------------------------------------------------------------------------- 1 | import sql from "./db.ts"; 2 | import {calcHash} from "../utils/index.ts"; 3 | 4 | 5 | /** 6 | * 创建 bookid 表 7 | */ 8 | export async function createBookIdTable(): Promise { 9 | await sql` 10 | CREATE TABLE IF NOT EXISTS bookid ( 11 | book_id text PRIMARY KEY, 12 | hash text NOT NULL 13 | ) 14 | `; 15 | } 16 | 17 | /** 18 | * 插入数据 19 | * @param bookIds 20 | */ 21 | export async function insertBookIds(bookIds: string[]): Promise { 22 | let count = 0 23 | 24 | for (const bookId of bookIds) { 25 | const hash = calcHash(bookId) 26 | try { 27 | await sql` 28 | INSERT INTO bookid (book_id, hash) 29 | VALUES (${bookId}, ${hash}) 30 | `; 31 | console.log(++count) 32 | } catch (e) { 33 | if (e.message === 'duplicate key value violates unique constraint "bookid_pkey"') { 34 | // 重复插入 35 | } else { 36 | console.log(e) 37 | } 38 | } 39 | } 40 | } 41 | 42 | interface BookIdEntry { 43 | book_id: string 44 | } 45 | 46 | /** 47 | * 查询 48 | * @param hash 49 | */ 50 | export async function search(hash: string): Promise { 51 | const result = await sql`SELECT book_id FROM bookid WHERE hash = ${hash}` 52 | if (result.count === 0) { 53 | return '' 54 | } else if (result.count === 1) { 55 | return result[0].book_id 56 | } 57 | 58 | console.warn(result) 59 | throw Error(`hash(${hash}) 搜索异常`) 60 | } 61 | -------------------------------------------------------------------------------- /src/database/db.ts: -------------------------------------------------------------------------------- 1 | import {runInDenoDeploy} from "../utils/index.ts"; 2 | import {dotenv, postgres} from "../deps.ts" 3 | 4 | 5 | const env = await dotenv.load() 6 | 7 | let databaseUrl: string 8 | 9 | if (runInDenoDeploy()) { 10 | databaseUrl = Deno.env.get("DATABASE_URL")!; 11 | } else { 12 | databaseUrl = env["DATABASE_URL"]; 13 | } 14 | 15 | const sql = postgres.default(databaseUrl, { 16 | onnotice: () => {}, 17 | }) 18 | 19 | export default sql 20 | -------------------------------------------------------------------------------- /src/database/download.ts: -------------------------------------------------------------------------------- 1 | import sql from "./db.ts"; 2 | 3 | 4 | /** 5 | * 创建 download 表 6 | */ 7 | export async function createTable(): Promise { 8 | await sql` 9 | CREATE TABLE IF NOT EXISTS download ( 10 | id serial PRIMARY KEY, 11 | vid text NOT NULL, 12 | book_id text NOT NULL, 13 | timestamp timestamp NOT NULL 14 | ) 15 | `; 16 | } 17 | 18 | 19 | export interface DownloadRecord { 20 | vid: string 21 | book_id: string 22 | timestamp: string 23 | } 24 | 25 | /** 26 | * 插入数据 27 | * @param records 28 | */ 29 | export async function insertDownloadRecords(records: DownloadRecord[]): Promise { 30 | await createTable() 31 | 32 | for (const record of records) { 33 | try { 34 | await sql`insert into download ${sql(record)}`; 35 | } catch (e) { 36 | if (e.message === 'duplicate key value violates unique constraint "bookid_pkey"') { 37 | // 重复插入 38 | } else { 39 | console.warn(e) 40 | } 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/database/log.ts: -------------------------------------------------------------------------------- 1 | import sql from "./db.ts"; 2 | 3 | 4 | /** 5 | * 创建 log 表 6 | */ 7 | export async function createTable(): Promise { 8 | await sql` 9 | CREATE TABLE IF NOT EXISTS log ( 10 | id serial PRIMARY KEY, 11 | subhoster_id text, 12 | deployment_id text NOT NULL, 13 | isolate_id text NOT NULL, 14 | region text NOT NULL, 15 | level text NOT NULL, 16 | timestamp timestamptz NOT NULL, 17 | message text NOT NULL, 18 | hash text NOT NULL, 19 | UNIQUE(hash) 20 | ) 21 | `; 22 | } 23 | 24 | 25 | interface LogRecord { 26 | subhoster_id: string 27 | deployment_id: string 28 | isolate_id: string 29 | region: string 30 | level: string 31 | timestamp: string 32 | message: string 33 | hash: string 34 | } 35 | 36 | /** 37 | * 插入数据 38 | * @param records 39 | */ 40 | export async function insertLogRecords(records: LogRecord[]): Promise { 41 | await createTable() 42 | 43 | for (const record of records) { 44 | try { 45 | await sql`insert into log ${sql(record)}`; 46 | } catch (e) { 47 | if (e.message === 'duplicate key value violates unique constraint "log_hash_key"') { 48 | // 重复插入 49 | } else { 50 | console.warn(e) 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/deps.ts: -------------------------------------------------------------------------------- 1 | export * as fs from "https://deno.land/std@0.194.0/http/file_server.ts"; 2 | export * as crypto from "https://deno.land/std@0.201.0/crypto/mod.ts"; 3 | export * as base64 from "https://deno.land/std@0.201.0/encoding/base64.ts"; 4 | export * as dotenv from "https://deno.land/std@0.202.0/dotenv/mod.ts"; 5 | export * as ulid from "https://deno.land/x/ulid@v0.3.0/mod.ts"; 6 | export * as postgres from "https://deno.land/x/postgresjs@v3.3.5/mod.js"; 7 | export * as parse5 from "npm:parse5@7.1.2"; 8 | export * as xss from "npm:xss@1.0.14"; 9 | // export * as csstree from "npm:css-tree@2.3.1"; 10 | -------------------------------------------------------------------------------- /src/frontend/apis/common.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore-file no-explicit-any 2 | import * as credentialUtil from "../../kv/credential.ts"; 3 | import {jsonResponse} from "../../utils/index.ts"; 4 | import {web_login_renewal} from "../../apis/web/login.ts"; 5 | import type {Credential} from "../../kv/credential.ts"; 6 | 7 | export enum ResponseCode { 8 | /** 9 | * 成功 10 | */ 11 | Success = 0, 12 | 13 | /** 14 | * 错误 15 | */ 16 | Error, 17 | 18 | /** 19 | * token无效 20 | */ 21 | CredentialError, 22 | 23 | /** 24 | * 参数错误 25 | */ 26 | ParamError, 27 | 28 | /** 29 | * 下载超限额 30 | */ 31 | CountLimit, 32 | } 33 | 34 | export interface ApiCallResponse { 35 | code: ResponseCode, 36 | msg: string 37 | data?: any 38 | } 39 | 40 | 41 | export interface ParamCheckEntity { 42 | name: string; 43 | from: "header" | "query"; 44 | statusCode: number; 45 | statusText: string; 46 | } 47 | 48 | export async function checkParams( 49 | req: Request, 50 | params: ParamCheckEntity[], 51 | ): Promise | Response> { 52 | const result: Record = {}; 53 | for (const p of params) { 54 | let value; 55 | if (p.from === "header") { 56 | value = req.headers.get(p.name); 57 | } else if (p.from === "query") { 58 | const query = new URL(req.url).searchParams; 59 | value = query.get(p.name); 60 | } else { 61 | console.warn(`暂不支持from参数: ${p.from}`); 62 | } 63 | 64 | if (!value || value === "null") { 65 | return jsonResponse({code: p.statusCode, msg: p.statusText}) 66 | } 67 | 68 | if (p.name === "token") { 69 | // 检查 token 是否合法 70 | const credential = await credentialUtil.getByToken(value) 71 | if (!credential.token) { 72 | return jsonResponse({code: p.statusCode, msg: p.statusText}) 73 | } 74 | } 75 | 76 | result[p.name] = value; 77 | } 78 | return result; 79 | } 80 | 81 | 82 | export async function apiCallWithRetry( 83 | req: Request, 84 | params: ParamCheckEntity[], 85 | apiCall: (params: Record, credential: Credential) => Promise, 86 | retry = 3 87 | ): Promise { 88 | // 检查参数 89 | const result = await checkParams(req, params); 90 | if (result instanceof Response) { 91 | return result; 92 | } 93 | 94 | const {token} = result; 95 | if (!token) { 96 | console.warn(`调用接口 ${req.url} 时 token 为空`) 97 | } 98 | 99 | async function executor(retry: number) { 100 | const credential = await credentialUtil.getByToken(token) 101 | const cookie = credentialUtil.getCookieByCredential(credential) 102 | 103 | const resp = await apiCall(result as Record, credential) 104 | if (resp instanceof Response) { 105 | return resp 106 | } 107 | 108 | if (resp && 109 | ( 110 | [-2010, -2012, -2013].includes(resp.errCode) || 111 | [-2010, -2012, -2013].includes(resp.errcode) 112 | ) && 113 | retry > 0) { 114 | // skey过期,重新刷新 115 | try { 116 | const credentialInfo = await web_login_renewal("/web/shelf/sync", cookie); 117 | const {accessToken, refreshToken} = credentialInfo; 118 | credential.skey = accessToken 119 | credential.rt = refreshToken 120 | credential.updatedAt = Date.now() 121 | await credentialUtil.update(credential) 122 | 123 | // 重新调用原始接口 124 | return executor(--retry); 125 | } catch (e) { 126 | console.error(e); 127 | // 可能是鉴权失败,需要重新登录 128 | return jsonResponse({code: ResponseCode.CredentialError, msg: e.message}) 129 | } 130 | } 131 | return jsonResponse({code: ResponseCode.Success, data: resp, msg: 'success'}) 132 | } 133 | 134 | return await executor(retry) 135 | } 136 | 137 | 138 | const encoder = new TextEncoder(); 139 | 140 | export type EventType = "close" | "error" | "progress" | "complete" | "qrcode" | "token" | "expired"; 141 | 142 | 143 | /** 144 | * 发送 SSE 事件 145 | * @param isClosed 连接是否已关闭 146 | * @param controller 147 | * @param type 事件类型 148 | * @param data 事件数据 149 | */ 150 | export function sendEvent( 151 | isClosed: boolean, 152 | controller: ReadableStreamDefaultController, 153 | type: EventType, 154 | data?: any, 155 | ) { 156 | let payload 157 | if (type === "qrcode") { 158 | payload = `event: ${type}\ndata: ${encodeURIComponent(data)}\n\n`; 159 | } else { 160 | payload = `event: ${type}\ndata: ${JSON.stringify(data)}\n\n`; 161 | } 162 | 163 | if (!isClosed) { 164 | controller.enqueue(encoder.encode(payload)); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/frontend/apis/downloadSSE.ts: -------------------------------------------------------------------------------- 1 | import * as credentialUtil from "../../kv/credential.ts"; 2 | import { 3 | web_book_chapter_e, 4 | web_book_chapter_e0, 5 | web_book_chapter_e1, 6 | web_book_chapter_e2, 7 | web_book_chapter_e3, 8 | web_book_chapter_t0, 9 | web_book_chapter_t1, 10 | web_book_info 11 | } from "../../apis/web/book.ts"; 12 | import {sleep} from "../../utils/index.ts"; 13 | import {incrementDownloadCount} from "../../kv/download.ts"; 14 | import {sendEvent} from "./common.ts"; 15 | import {Credential} from "../../kv/credential.ts"; 16 | import {processHtmls, processStyles} from "../../utils/process.ts"; 17 | import styleParser from "../../utils/style.ts"; 18 | import htmlParser from "../../utils/html.ts"; 19 | import {dH, dS, dT} from "../../utils/decrypt.ts"; 20 | 21 | 22 | /** 23 | * 下载 24 | */ 25 | export function downloadSSE( 26 | bookId: string, 27 | chapterUids: number[], 28 | credential: Credential, 29 | ): Response { 30 | let isClosed = false; 31 | const body = new ReadableStream({ 32 | start: async (controller) => { 33 | try { 34 | const cookie = credentialUtil.getCookieByCredential(credential) 35 | 36 | for (const chapterUid of chapterUids) { 37 | if (isClosed) { 38 | return; 39 | } 40 | 41 | // 单章下载 42 | const html = await web_book_chapter_e(bookId, chapterUid, cookie); 43 | const data = {total: chapterUids.length, current: chapterUid, content: html} 44 | sendEvent(isClosed, controller, "progress", data); 45 | 46 | await sleep(300); 47 | } 48 | 49 | const fileRe = /^file:\/\// 50 | const resetStyle = Deno.readTextFileSync(import.meta.resolve("../assets/styles/reset.css").replace(fileRe, '')) 51 | const footerNoteStyle = Deno.readTextFileSync( 52 | import.meta.resolve("../assets/styles/footer_note.css").replace(fileRe, ""), 53 | ); 54 | const footerNoteScript = Deno.readTextFileSync( 55 | import.meta.resolve("../assets/js/footer_note.js").replace(fileRe, "") 56 | ) 57 | const extra = {styles: [resetStyle, footerNoteStyle], scripts: [footerNoteScript]} 58 | sendEvent(isClosed, controller, "complete", extra); 59 | 60 | await incrementDownloadCount(credential, bookId); 61 | } catch (e) { 62 | console.error(e); 63 | sendEvent(isClosed, controller, "error", e.message); 64 | } finally { 65 | isClosed = true; 66 | sendEvent(isClosed, controller, "close"); 67 | } 68 | }, 69 | cancel(reason) { 70 | console.debug('downloadSSE: ', reason); 71 | isClosed = true; 72 | }, 73 | }); 74 | 75 | return new Response(body, { 76 | headers: { 77 | "Content-Type": "text/event-stream", 78 | "Access-Control-Allow-Origin": "*", 79 | }, 80 | }); 81 | } 82 | 83 | 84 | /** 85 | * 下载章节内容 86 | * @param bookId 87 | * @param chapterUid 88 | * @param cookie 89 | */ 90 | export async function download_chapter( 91 | bookId: string, 92 | chapterUid: number, 93 | cookie = "", 94 | ): Promise { 95 | let promise: Promise<[string[], string | null]>; 96 | const resp = await web_book_info(bookId, cookie); 97 | console.log(resp) 98 | const {format} = resp 99 | if (format === "epub" || format === "pdf") { 100 | promise = Promise.all([ 101 | web_book_chapter_e0(bookId, chapterUid, cookie), 102 | web_book_chapter_e1(bookId, chapterUid, cookie), 103 | web_book_chapter_e2(bookId, chapterUid, cookie), 104 | web_book_chapter_e3(bookId, chapterUid, cookie), 105 | ]).then((results) => { 106 | if ( 107 | "string" == typeof results[0] && results[0].length > 0 && 108 | "string" == typeof results[1] && results[1].length > 0 && 109 | "string" == typeof results[3] && results[3].length > 0 110 | ) { 111 | let styles = dS(results[2]); 112 | styles = styleParser.parse(styles, { 113 | removeFontSizes: true, 114 | enableTranslate: false, 115 | }); 116 | 117 | const html = dH(results[0] + results[1] + results[3]); 118 | const htmls = htmlParser.parse(html, styles, 10000); 119 | return [htmls, styles]; 120 | } else { 121 | console.log(results); 122 | throw Error(`下载失败(${bookId})`); 123 | } 124 | }); 125 | } else if (format === "txt") { 126 | promise = Promise.all([ 127 | web_book_chapter_t0(bookId, chapterUid, cookie), 128 | web_book_chapter_t1(bookId, chapterUid, cookie), 129 | ]).then((results) => { 130 | if ( 131 | "string" === typeof results[0] && results[0].length > 0 && 132 | "string" == typeof results[1] && results[1].length > 0 133 | ) { 134 | const html = dT(results[0] + results[1]); 135 | const htmls = htmlParser.parseTxt(html, 10000); 136 | return [htmls, null]; 137 | } else { 138 | console.log(results); 139 | throw Error("下载失败"); 140 | } 141 | }); 142 | } else { 143 | throw Error(`暂不支持${format}格式(${bookId})`); 144 | } 145 | 146 | let [htmls, styles] = await promise; 147 | 148 | // 处理style 149 | if (styles) { 150 | styles = processStyles(styles, bookId); 151 | } 152 | 153 | // 处理html 154 | htmls = processHtmls(htmls, bookId); 155 | 156 | // 对 html 进行一些处理 157 | const sections = htmls.map((html) => { 158 | // 图片的处理 159 | // 去掉 base64 图片地址(该图片是占位符) 160 | html = html.replaceAll(/(]+?)(src="data:[^"]+")/gs, "$1"); 161 | // 将 data-src 替换成 src 162 | html = html.replaceAll(/(]+?)data-src="/gs, '$1src="'); 163 | 164 | // 剥离body外壳 165 | const bodyRe = /^<\/head>(?.*)<\/body><\/html>$/s; 166 | const match = html.match(bodyRe); 167 | if (match) { 168 | return match.groups!.body; 169 | } 170 | return html; 171 | }).join(""); 172 | 173 | return ` 174 |
175 | 176 | ${sections} 177 |
178 | `; 179 | } 180 | -------------------------------------------------------------------------------- /src/frontend/apis/loginSSE.ts: -------------------------------------------------------------------------------- 1 | import { 2 | web_confirm, 3 | web_login_getinfo, 4 | web_login_getuid, 5 | web_login_session_init, 6 | web_login_weblogin, 7 | } from "../../apis/web/login.ts"; 8 | import * as credentialUtil from "../../kv/credential.ts"; 9 | import * as taskManager from "../../kv/task.ts" 10 | import {getUlid} from "../../utils/index.ts"; 11 | import {sendEvent} from "./common.ts"; 12 | import {web_user} from "../../apis/web/user.ts"; 13 | import {Credential} from "../../kv/credential.ts"; 14 | 15 | 16 | /** 17 | * 登录 18 | * @param _ 19 | */ 20 | export function loginSSE(_: Request): Response { 21 | let isClosed = false; 22 | const body = new ReadableStream({ 23 | start: async (controller) => { 24 | try { 25 | const {uid} = await web_login_getuid(); 26 | const url = web_confirm(uid); 27 | sendEvent(isClosed, controller, "qrcode", url); 28 | 29 | const info = await web_login_getinfo(uid); 30 | if (isClosed) { 31 | return 32 | } 33 | if (info.scan === 0) { 34 | // 超时未扫码,前端二维码过期 35 | sendEvent(isClosed, controller, "expired"); 36 | return; 37 | } 38 | 39 | const auth = await web_login_weblogin(info); 40 | const resp = await web_login_session_init(auth); 41 | if (resp.success === 1) { 42 | // 登录成功,生成一个随机数作为前端的 token 43 | const token = getUlid(); 44 | // 获取用户名 45 | const userResp = await web_user(auth.vid, `wr_vid=${auth.vid};wr_skey=${auth.accessToken};wr_rt=${auth.refreshToken};`) 46 | const credential: Credential = { 47 | token: token, 48 | vid: Number(auth.vid), 49 | skey: auth.accessToken, 50 | rt: auth.refreshToken, 51 | updatedAt: Date.now(), 52 | name: userResp.name || 'unknown', 53 | } 54 | await credentialUtil.update(credential) 55 | sendEvent(isClosed, controller, "token", token); 56 | 57 | // 更新阅读任务中的token 58 | await taskManager.updateTaskToken(credential) 59 | } else { 60 | console.warn("会话初始化失败: ", resp); 61 | } 62 | } catch (e) { 63 | console.error(e); 64 | sendEvent(isClosed, controller, "error", e.message); 65 | } finally { 66 | sendEvent(isClosed, controller, "close"); 67 | } 68 | }, 69 | cancel(reason) { 70 | console.debug('loginSSE: ', reason); 71 | isClosed = true 72 | }, 73 | }); 74 | 75 | return new Response(body, { 76 | headers: { 77 | "Content-Type": "text/event-stream", 78 | "Access-Control-Allow-Origin": "*", 79 | }, 80 | }); 81 | } 82 | -------------------------------------------------------------------------------- /src/frontend/apis/misc.ts: -------------------------------------------------------------------------------- 1 | import {apiCallWithRetry, ParamCheckEntity, ResponseCode} from "./common.ts"; 2 | import {Credential} from "../../kv/credential.ts"; 3 | import * as credentialUtil from "../../kv/credential.ts"; 4 | import {getPDFUrl} from "../../apis/web/misc.ts"; 5 | 6 | /** 7 | * 下载 pdf 书籍 8 | * @param req 9 | */ 10 | export async function getPdfUrl(req: Request) { 11 | const params: ParamCheckEntity[] = [ 12 | { 13 | name: "token", 14 | from: "header", 15 | statusCode: ResponseCode.CredentialError, 16 | statusText: "token无效", 17 | }, 18 | { 19 | name: 'bookId', 20 | from: 'header', 21 | statusCode: ResponseCode.ParamError, 22 | statusText: 'bookId不能为空', 23 | }, 24 | ]; 25 | 26 | return await apiCallWithRetry(req, params, ({bookId}, credential: Credential) => { 27 | const cookie = credentialUtil.getCookieByCredential(credential) 28 | return getPDFUrl(bookId, cookie) 29 | }) 30 | } 31 | -------------------------------------------------------------------------------- /src/frontend/apis/review.ts: -------------------------------------------------------------------------------- 1 | import {apiCallWithRetry, ParamCheckEntity, ResponseCode} from "./common.ts"; 2 | import {web_review_list} from "../../apis/web/review.ts"; 3 | import type {Credential} from "../../kv/credential.ts"; 4 | import * as credentialUtil from "../../kv/credential.ts" 5 | 6 | /** 7 | * 获取笔记列表 8 | * @param req 9 | */ 10 | export async function reviewList(req: Request) { 11 | const params: ParamCheckEntity[] = [ 12 | { 13 | name: "token", 14 | from: "header", 15 | statusCode: ResponseCode.CredentialError, 16 | statusText: "token无效", 17 | }, 18 | { 19 | name: 'bookId', 20 | from: 'header', 21 | statusCode: ResponseCode.ParamError, 22 | statusText: 'bookId不能为空', 23 | }, 24 | ]; 25 | 26 | return await apiCallWithRetry(req, params, (_, credential: Credential) => { 27 | return web_review_list(credentialUtil.getCookieByCredential(credential)); 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /src/frontend/apis/shelf.ts: -------------------------------------------------------------------------------- 1 | import {web_shelf_sync} from "../../apis/web/shelf.ts"; 2 | import {web_book_chapterInfos, web_book_info} from "../../apis/web/book.ts"; 3 | import {downloadSSE} from "./downloadSSE.ts"; 4 | import { 5 | checkDownloadCount, 6 | newDownloadSecret, 7 | useSecret, 8 | } from "../../kv/download.ts"; 9 | import {MAX_DOWNLOAD_COUNT_PER_MONTH} from "../../config.ts"; 10 | import {ResponseCode, ParamCheckEntity, apiCallWithRetry} from "./common.ts"; 11 | import {jsonResponse} from "../../utils/index.ts"; 12 | import {search} from "../../database/bookid.ts"; 13 | import * as credentialUtil from "../../kv/credential.ts"; 14 | import type {Credential} from "../../kv/credential.ts"; 15 | 16 | 17 | /** 18 | * 获取图书列表 19 | * @param req 20 | */ 21 | export async function bookList(req: Request) { 22 | const params: ParamCheckEntity[] = [ 23 | { 24 | name: "token", 25 | from: "header", 26 | statusCode: ResponseCode.CredentialError, 27 | statusText: "token无效", 28 | }, 29 | ]; 30 | return await apiCallWithRetry(req, params, (_, credential: Credential) => { 31 | return web_shelf_sync({}, credentialUtil.getCookieByCredential(credential)) 32 | }) 33 | } 34 | 35 | 36 | /** 37 | * 获取图书详情 38 | * @param req 39 | */ 40 | export async function bookDetail(req: Request) { 41 | const params: ParamCheckEntity[] = [ 42 | { 43 | name: "token", 44 | from: "header", 45 | statusCode: ResponseCode.CredentialError, 46 | statusText: "token无效", 47 | }, 48 | { 49 | name: "bookId", 50 | from: "header", 51 | statusCode: ResponseCode.ParamError, 52 | statusText: "bookId不能为空", 53 | }, 54 | ]; 55 | 56 | return await apiCallWithRetry(req, params, ({bookId}: Record, credential: Credential) => { 57 | return web_book_info(bookId, credentialUtil.getCookieByCredential(credential)) 58 | }) 59 | } 60 | 61 | /** 62 | * 获取图书章节信息 63 | * @param req 64 | */ 65 | export async function bookChapters(req: Request) { 66 | const params: ParamCheckEntity[] = [ 67 | { 68 | name: "token", 69 | from: "header", 70 | statusCode: ResponseCode.CredentialError, 71 | statusText: "token无效", 72 | }, 73 | { 74 | name: "bookId", 75 | from: "header", 76 | statusCode: ResponseCode.ParamError, 77 | statusText: "bookId不能为空", 78 | }, 79 | ]; 80 | 81 | return await apiCallWithRetry(req, params, ({bookId}: Record, credential: Credential) => { 82 | return web_book_chapterInfos([bookId], credentialUtil.getCookieByCredential(credential)) 83 | }) 84 | } 85 | 86 | /** 87 | * 下载 88 | * @param req 89 | */ 90 | export async function bookDownload(req: Request) { 91 | const params: ParamCheckEntity[] = [ 92 | { 93 | name: "token", 94 | from: "query", 95 | statusCode: ResponseCode.CredentialError, 96 | statusText: "token无效", 97 | }, 98 | { 99 | name: "secret", 100 | from: "query", 101 | statusCode: ResponseCode.ParamError, 102 | statusText: "secret不能为空", 103 | }, 104 | ]; 105 | 106 | return await apiCallWithRetry(req, params, async ({secret}: Record, credential: Credential) => { 107 | const [ok, bookId, chapterUids] = await useSecret(credential, secret); 108 | if (ok) { 109 | return downloadSSE(bookId, chapterUids, credential); 110 | } else { 111 | return jsonResponse({code: ResponseCode.ParamError, msg: 'secret无效'}) 112 | } 113 | }) 114 | } 115 | 116 | /** 117 | * 获取下载凭证 118 | * @param req 119 | */ 120 | export async function getDownloadSecret(req: Request) { 121 | const params: ParamCheckEntity[] = [ 122 | { 123 | name: "token", 124 | from: "header", 125 | statusCode: ResponseCode.CredentialError, 126 | statusText: "token无效", 127 | }, 128 | { 129 | name: "bookId", 130 | from: "header", 131 | statusCode: ResponseCode.ParamError, 132 | statusText: "bookId不能为空", 133 | }, 134 | { 135 | name: "chapterUids", 136 | from: "header", 137 | statusCode: ResponseCode.ParamError, 138 | statusText: "chapterUids不能为空", 139 | }, 140 | ]; 141 | 142 | return await apiCallWithRetry(req, params, async ({bookId, chapterUids}: Record, credential: Credential) => { 143 | // 验证该用户的月下载量 144 | if (!(await checkDownloadCount(credential))) { 145 | // 无法下载 146 | return jsonResponse({code: ResponseCode.CountLimit, msg: `每月仅能下载${MAX_DOWNLOAD_COUNT_PER_MONTH}次,请下个月再试`}) 147 | } else { 148 | // 生成临时下载凭证 149 | const secret = await newDownloadSecret( 150 | credential, 151 | bookId, 152 | chapterUids.split("|").map((uid: string) => Number(uid)), 153 | ); 154 | return jsonResponse({code: ResponseCode.Success, data: secret, msg: 'success'}) 155 | } 156 | }) 157 | } 158 | 159 | /** 160 | * 书籍查询 161 | * @param req 162 | */ 163 | export async function bookSearch(req: Request) { 164 | const params: ParamCheckEntity[] = [ 165 | { 166 | name: "token", 167 | from: "header", 168 | statusCode: ResponseCode.CredentialError, 169 | statusText: "token无效", 170 | }, 171 | { 172 | name: "hash", 173 | from: "header", 174 | statusCode: ResponseCode.ParamError, 175 | statusText: "hash无效", 176 | }, 177 | ]; 178 | 179 | return await apiCallWithRetry(req, params, async ({hash}: Record) => { 180 | console.log(hash) 181 | const bookId = await search(hash) 182 | console.log('search bookId: ', bookId) 183 | if (bookId) { 184 | return jsonResponse({code: ResponseCode.Success, data: bookId, msg: 'success'}) 185 | } else { 186 | return jsonResponse({code: ResponseCode.Error, msg: '未找到,欢迎提供反馈'}) 187 | } 188 | }) 189 | } 190 | -------------------------------------------------------------------------------- /src/frontend/apis/task.ts: -------------------------------------------------------------------------------- 1 | import {jsonResponse, randomInteger} from "../../utils/index.ts"; 2 | import {web_book_publicinfos, web_book_read_init} from "../../apis/web/book.ts"; 3 | import {apiCallWithRetry, ParamCheckEntity, ResponseCode} from "./common.ts"; 4 | import {friend_ranking} from "../../apis/app/friend.ts"; 5 | import type {Credential} from "../../kv/credential.ts"; 6 | import type {BookInfo} from "../../kv/task.ts"; 7 | import * as taskManager from "../../kv/task.ts"; 8 | import * as credentialUtil from "../../kv/credential.ts"; 9 | import {getConfig} from "../../apis/web/misc.ts"; 10 | 11 | 12 | /** 13 | * 朋友排行榜 14 | * @param req 15 | */ 16 | export async function friendRank(req: Request) { 17 | const params: ParamCheckEntity[] = [ 18 | { 19 | name: "token", 20 | from: "header", 21 | statusCode: ResponseCode.CredentialError, 22 | statusText: "token无效", 23 | }, 24 | ]; 25 | 26 | return await apiCallWithRetry(req, params, (_, credential: Credential) => { 27 | return friend_ranking(credential.vid, credential.skey) 28 | }) 29 | } 30 | 31 | /** 32 | * 添加自动阅读任务 33 | */ 34 | export async function startRead(req: Request) { 35 | const params: ParamCheckEntity[] = [ 36 | { 37 | name: "token", 38 | from: "header", 39 | statusCode: ResponseCode.CredentialError, 40 | statusText: "token无效", 41 | }, 42 | { 43 | name: "bookId", 44 | from: "header", 45 | statusCode: ResponseCode.ParamError, 46 | statusText: "bookId不能为空", 47 | }, 48 | ]; 49 | 50 | // 计算服务器及客户端加载的时间戳 51 | const pc = Math.floor(new Date().getTime() / 1000) 52 | const ps = pc - randomInteger(2, 10) 53 | 54 | return await apiCallWithRetry(req, params, async ({bookId}: Record, credential: Credential) => { 55 | const cookie = credentialUtil.getCookieByCredential(credential) 56 | 57 | // 获取书籍信息 58 | const bookInfos = await web_book_publicinfos([bookId]) 59 | const book = bookInfos.data[0] 60 | if (book.bookId !== bookId) { 61 | return jsonResponse({code: ResponseCode.Error, msg: '获取书籍数据错误'}) 62 | } 63 | const bookInfo: BookInfo = { 64 | bookId: bookId, 65 | title: book.title, 66 | author: book.author, 67 | } 68 | 69 | // 获取阅读器token 70 | const {token: readerToken} = await getConfig(cookie) 71 | if (!readerToken) { 72 | return jsonResponse({code: ResponseCode.Error, msg: '获取阅读器token失败'}) 73 | } 74 | 75 | // 开始阅读 76 | const result = await web_book_read_init( 77 | bookId, 78 | 2, 79 | 0, 80 | 0, 81 | pc, 82 | ps, 83 | "epub", 84 | cookie, 85 | ) 86 | if (result.succ === 1 && result.synckey) { 87 | // 添加到 kv 中 88 | await taskManager.setReaderToken(readerToken) 89 | await taskManager.addReadingTask(credential, bookInfo, pc, ps) 90 | console.debug(`开始阅读成功: (vid: ${credential.vid}, name:${credential.name}, book: ${book.title})`) 91 | } else { 92 | console.warn(`开始阅读失败: (vid: ${credential.vid}, name:${credential.name}, book: ${book.title})`) 93 | console.warn(result) 94 | } 95 | return result 96 | }) 97 | } 98 | 99 | /** 100 | * 取消自动阅读任务 101 | * @param req 102 | */ 103 | export async function stopRead(req: Request) { 104 | const params: ParamCheckEntity[] = [ 105 | { 106 | name: "token", 107 | from: "header", 108 | statusCode: ResponseCode.CredentialError, 109 | statusText: "token无效", 110 | }, 111 | ]; 112 | 113 | return await apiCallWithRetry(req, params, async (_, credential: Credential) => { 114 | await taskManager.removeReadingTask(credential) 115 | return jsonResponse({code: ResponseCode.Success, msg: '取消成功'}) 116 | }) 117 | } 118 | 119 | /** 120 | * 查询用户的任务 121 | * @param req 122 | */ 123 | export async function queryTask(req: Request) { 124 | const params: ParamCheckEntity[] = [ 125 | { 126 | name: "token", 127 | from: "header", 128 | statusCode: ResponseCode.CredentialError, 129 | statusText: "token无效", 130 | }, 131 | ]; 132 | 133 | return await apiCallWithRetry(req, params, async (_, credential: Credential) => { 134 | const task = await taskManager.getReadingTask(credential) 135 | // 删除掉敏感数据 136 | let payload = null 137 | if (task) { 138 | payload = { 139 | ...task, 140 | credential: null 141 | } 142 | } 143 | 144 | return jsonResponse({code: ResponseCode.Success, data: payload, msg: 'success'}) 145 | }) 146 | } 147 | -------------------------------------------------------------------------------- /src/frontend/apis/user.ts: -------------------------------------------------------------------------------- 1 | import * as credentialUtil from "../../kv/credential.ts"; 2 | import {ParamCheckEntity, ResponseCode, apiCallWithRetry} from "./common.ts"; 3 | import {web_user} from "../../apis/web/user.ts" 4 | import type {Credential} from "../../kv/credential.ts"; 5 | 6 | /** 7 | * 获取用户信息 8 | * @param req 9 | */ 10 | export async function userInfo(req: Request) { 11 | const params: ParamCheckEntity[] = [ 12 | { 13 | name: "token", 14 | from: "header", 15 | statusCode: ResponseCode.CredentialError, 16 | statusText: "token无效", 17 | }, 18 | ]; 19 | 20 | return await apiCallWithRetry(req, params, async (_, credential: Credential) => { 21 | return await web_user(credential.vid, credentialUtil.getCookieByCredential(credential)); 22 | }) 23 | } 24 | -------------------------------------------------------------------------------- /src/frontend/assets/js/footer_note.js: -------------------------------------------------------------------------------- 1 | console.log(123) 2 | -------------------------------------------------------------------------------- /src/frontend/assets/styles/footer_note.css: -------------------------------------------------------------------------------- 1 | /* 携带注释信息的元素,下面的样式用来让它显示为一个黑色的圆 */ 2 | span.reader_footer_note { 3 | text-indent: 0; /* 避免继承段落的缩进样式 */ 4 | text-align: left; /* 文字左对齐 */ 5 | position: relative; /* 用来给伪元素做定位参照 */ 6 | display: inline-block; /* 使宽度和高度指定有效 */ 7 | width: 1em; /* 设定宽度 */ 8 | height: 1em; /* 设定高度 */ 9 | background-color: black; /* 设定背景为黑色 */ 10 | vertical-align: super; /* 设置为上标形式 */ 11 | border-radius: 50%; /* 圆角化为圆形 */ 12 | cursor: pointer; /* 光标样式改为手指 */ 13 | } 14 | 15 | /* before 伪元素用来显示“注”这个字 */ 16 | span.reader_footer_note:before { 17 | position: absolute; /* 绝对位置,基准为 span.reader_footer_note */ 18 | content: "注"; /* 显示“注”字 */ 19 | color: white; /* 字颜色为白色 */ 20 | left: 0.15em; /* 微调字的位置 */ 21 | top: 0.1em; /* 微调字的位置 */ 22 | font-size: 0.75em; /* 设定文字大小 */ 23 | font-family: "汉仪楷体"; /* 设定字体 */ 24 | } 25 | 26 | /* after 伪元素用来显示注释内容,只在光标移至“注”上方时才显示 */ 27 | span.reader_footer_note:hover:after { 28 | position: fixed; /* 相对于视窗的位置 */ 29 | content: attr(data-wr-footernote); /* 获取并设置注释内容 */ 30 | left: 0; /* 设定相对于视窗的位置 */ 31 | bottom: 0; /* 设定相对于视窗的位置 */ 32 | margin: 1em; /* 设定背景气泡与视窗边缘预留的空间 */ 33 | background: black; /* 设定背景气泡为黑色 */ 34 | border-radius: 0.25em; /* 背景气泡圆角 */ 35 | color: white; /* 设定文字为白色 */ 36 | padding: 0.5em; /* 设定文字内容与背景气泡边缘预留的空间 */ 37 | font-size: 1em; /* 设定文字大小 */ 38 | font-family: "汉仪楷体"; /* 设定字体 */ 39 | z-index: 1; /* 避免被其它元素遮挡 */ 40 | } 41 | -------------------------------------------------------------------------------- /src/frontend/assets/styles/reset.css: -------------------------------------------------------------------------------- 1 | img, picture, video, canvas, svg { 2 | max-width: 100% !important; 3 | } 4 | -------------------------------------------------------------------------------- /src/frontend/www/detail.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 图书详情 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 33 | 34 |
35 |
36 |
37 |
38 | 书籍封面 39 |
40 | 41 |
42 |
43 |
44 |
45 |

---

46 |
47 | 作者:--- 48 |
49 |
50 | 51 |
52 |
简介:---
53 |
54 |
55 | 56 |
57 |
58 |

详细信息

59 |
60 | 出版社 61 |
62 |
63 | 出版时间 64 |
65 | 66 | 67 | 68 | 69 |
70 | 原始格式 71 |

72 | 73 | 下载 74 |

75 |
76 |
77 | 其他可用格式 78 | 79 |
80 |
81 | ISBN 82 | 83 |
84 |
85 |
86 |

章节信息

87 |
    88 |
    89 |
    90 |
    91 |
    92 | 93 | 104 | 105 | 368 | 369 | 370 | -------------------------------------------------------------------------------- /src/frontend/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 我的书架 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 30 | 31 |
    32 |
    33 |
    34 |
    35 |
    36 |
    37 |
    38 |
    39 |
    40 |
    41 |
    42 |
    43 |
    44 |
    45 |
    46 |
    47 |
    48 |
    49 |
    50 |
    51 | 52 |
    53 |
    54 |
    55 | 56 | 67 | 68 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /src/frontend/www/lib/FileSaver.min.js: -------------------------------------------------------------------------------- 1 | (function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(a,b,c){var d=new XMLHttpRequest;d.open("GET",a),d.responseType="blob",d.onload=function(){g(d.response,b,c)},d.onerror=function(){console.error("could not download file")},d.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),g=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype&&!a?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(b,d,e,g){if(g=g||open("","_blank"),g&&(g.document.title=g.document.body.innerText="downloading..."),"string"==typeof b)return c(b,d,e);var h="application/octet-stream"===b.type,i=/constructor/i.test(f.HTMLElement)||f.safari,j=/CriOS\/[\d]+/.test(navigator.userAgent);if((j||h&&i||a)&&"undefined"!=typeof FileReader){var k=new FileReader;k.onloadend=function(){var a=k.result;a=j?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),g?g.location.href=a:location=a,g=null},k.readAsDataURL(b)}else{var l=f.URL||f.webkitURL,m=l.createObjectURL(b);g?g.location=m:location.href=m,g=null,setTimeout(function(){l.revokeObjectURL(m)},4E4)}});f.saveAs=g.saveAs=g,"undefined"!=typeof module&&(module.exports=g)}); 2 | 3 | //# sourceMappingURL=FileSaver.min.js.map -------------------------------------------------------------------------------- /src/frontend/www/lib/qrcode.min.js: -------------------------------------------------------------------------------- 1 | var QRCode;!function(){function a(a){this.mode=c.MODE_8BIT_BYTE,this.data=a,this.parsedData=[];for(var b=[],d=0,e=this.data.length;e>d;d++){var f=this.data.charCodeAt(d);f>65536?(b[0]=240|(1835008&f)>>>18,b[1]=128|(258048&f)>>>12,b[2]=128|(4032&f)>>>6,b[3]=128|63&f):f>2048?(b[0]=224|(61440&f)>>>12,b[1]=128|(4032&f)>>>6,b[2]=128|63&f):f>128?(b[0]=192|(1984&f)>>>6,b[1]=128|63&f):b[0]=f,this.parsedData=this.parsedData.concat(b)}this.parsedData.length!=this.data.length&&(this.parsedData.unshift(191),this.parsedData.unshift(187),this.parsedData.unshift(239))}function b(a,b){this.typeNumber=a,this.errorCorrectLevel=b,this.modules=null,this.moduleCount=0,this.dataCache=null,this.dataList=[]}function i(a,b){if(void 0==a.length)throw new Error(a.length+"/"+b);for(var c=0;c=f;f++){var h=0;switch(b){case d.L:h=l[f][0];break;case d.M:h=l[f][1];break;case d.Q:h=l[f][2];break;case d.H:h=l[f][3]}if(h>=e)break;c++}if(c>l.length)throw new Error("Too long data");return c}function s(a){var b=encodeURI(a).toString().replace(/\%[0-9a-fA-F]{2}/g,"a");return b.length+(b.length!=a?3:0)}a.prototype={getLength:function(){return this.parsedData.length},write:function(a){for(var b=0,c=this.parsedData.length;c>b;b++)a.put(this.parsedData[b],8)}},b.prototype={addData:function(b){var c=new a(b);this.dataList.push(c),this.dataCache=null},isDark:function(a,b){if(0>a||this.moduleCount<=a||0>b||this.moduleCount<=b)throw new Error(a+","+b);return this.modules[a][b]},getModuleCount:function(){return this.moduleCount},make:function(){this.makeImpl(!1,this.getBestMaskPattern())},makeImpl:function(a,c){this.moduleCount=4*this.typeNumber+17,this.modules=new Array(this.moduleCount);for(var d=0;d=7&&this.setupTypeNumber(a),null==this.dataCache&&(this.dataCache=b.createData(this.typeNumber,this.errorCorrectLevel,this.dataList)),this.mapData(this.dataCache,c)},setupPositionProbePattern:function(a,b){for(var c=-1;7>=c;c++)if(!(-1>=a+c||this.moduleCount<=a+c))for(var d=-1;7>=d;d++)-1>=b+d||this.moduleCount<=b+d||(this.modules[a+c][b+d]=c>=0&&6>=c&&(0==d||6==d)||d>=0&&6>=d&&(0==c||6==c)||c>=2&&4>=c&&d>=2&&4>=d?!0:!1)},getBestMaskPattern:function(){for(var a=0,b=0,c=0;8>c;c++){this.makeImpl(!0,c);var d=f.getLostPoint(this);(0==c||a>d)&&(a=d,b=c)}return b},createMovieClip:function(a,b,c){var d=a.createEmptyMovieClip(b,c),e=1;this.make();for(var f=0;f=g;g++)for(var h=-2;2>=h;h++)this.modules[d+g][e+h]=-2==g||2==g||-2==h||2==h||0==g&&0==h?!0:!1}},setupTypeNumber:function(a){for(var b=f.getBCHTypeNumber(this.typeNumber),c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[Math.floor(c/3)][c%3+this.moduleCount-8-3]=d}for(var c=0;18>c;c++){var d=!a&&1==(1&b>>c);this.modules[c%3+this.moduleCount-8-3][Math.floor(c/3)]=d}},setupTypeInfo:function(a,b){for(var c=this.errorCorrectLevel<<3|b,d=f.getBCHTypeInfo(c),e=0;15>e;e++){var g=!a&&1==(1&d>>e);6>e?this.modules[e][8]=g:8>e?this.modules[e+1][8]=g:this.modules[this.moduleCount-15+e][8]=g}for(var e=0;15>e;e++){var g=!a&&1==(1&d>>e);8>e?this.modules[8][this.moduleCount-e-1]=g:9>e?this.modules[8][15-e-1+1]=g:this.modules[8][15-e-1]=g}this.modules[this.moduleCount-8][8]=!a},mapData:function(a,b){for(var c=-1,d=this.moduleCount-1,e=7,g=0,h=this.moduleCount-1;h>0;h-=2)for(6==h&&h--;;){for(var i=0;2>i;i++)if(null==this.modules[d][h-i]){var j=!1;g>>e));var k=f.getMask(b,d,h-i);k&&(j=!j),this.modules[d][h-i]=j,e--,-1==e&&(g++,e=7)}if(d+=c,0>d||this.moduleCount<=d){d-=c,c=-c;break}}}},b.PAD0=236,b.PAD1=17,b.createData=function(a,c,d){for(var e=j.getRSBlocks(a,c),g=new k,h=0;h8*l)throw new Error("code length overflow. ("+g.getLengthInBits()+">"+8*l+")");for(g.getLengthInBits()+4<=8*l&&g.put(0,4);0!=g.getLengthInBits()%8;)g.putBit(!1);for(;;){if(g.getLengthInBits()>=8*l)break;if(g.put(b.PAD0,8),g.getLengthInBits()>=8*l)break;g.put(b.PAD1,8)}return b.createBytes(g,e)},b.createBytes=function(a,b){for(var c=0,d=0,e=0,g=new Array(b.length),h=new Array(b.length),j=0;j=0?p.get(q):0}}for(var r=0,m=0;mm;m++)for(var j=0;jm;m++)for(var j=0;j=0;)b^=f.G15<=0;)b^=f.G18<>>=1;return b},getPatternPosition:function(a){return f.PATTERN_POSITION_TABLE[a-1]},getMask:function(a,b,c){switch(a){case e.PATTERN000:return 0==(b+c)%2;case e.PATTERN001:return 0==b%2;case e.PATTERN010:return 0==c%3;case e.PATTERN011:return 0==(b+c)%3;case e.PATTERN100:return 0==(Math.floor(b/2)+Math.floor(c/3))%2;case e.PATTERN101:return 0==b*c%2+b*c%3;case e.PATTERN110:return 0==(b*c%2+b*c%3)%2;case e.PATTERN111:return 0==(b*c%3+(b+c)%2)%2;default:throw new Error("bad maskPattern:"+a)}},getErrorCorrectPolynomial:function(a){for(var b=new i([1],0),c=0;a>c;c++)b=b.multiply(new i([1,g.gexp(c)],0));return b},getLengthInBits:function(a,b){if(b>=1&&10>b)switch(a){case c.MODE_NUMBER:return 10;case c.MODE_ALPHA_NUM:return 9;case c.MODE_8BIT_BYTE:return 8;case c.MODE_KANJI:return 8;default:throw new Error("mode:"+a)}else if(27>b)switch(a){case c.MODE_NUMBER:return 12;case c.MODE_ALPHA_NUM:return 11;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 10;default:throw new Error("mode:"+a)}else{if(!(41>b))throw new Error("type:"+b);switch(a){case c.MODE_NUMBER:return 14;case c.MODE_ALPHA_NUM:return 13;case c.MODE_8BIT_BYTE:return 16;case c.MODE_KANJI:return 12;default:throw new Error("mode:"+a)}}},getLostPoint:function(a){for(var b=a.getModuleCount(),c=0,d=0;b>d;d++)for(var e=0;b>e;e++){for(var f=0,g=a.isDark(d,e),h=-1;1>=h;h++)if(!(0>d+h||d+h>=b))for(var i=-1;1>=i;i++)0>e+i||e+i>=b||(0!=h||0!=i)&&g==a.isDark(d+h,e+i)&&f++;f>5&&(c+=3+f-5)}for(var d=0;b-1>d;d++)for(var e=0;b-1>e;e++){var j=0;a.isDark(d,e)&&j++,a.isDark(d+1,e)&&j++,a.isDark(d,e+1)&&j++,a.isDark(d+1,e+1)&&j++,(0==j||4==j)&&(c+=3)}for(var d=0;b>d;d++)for(var e=0;b-6>e;e++)a.isDark(d,e)&&!a.isDark(d,e+1)&&a.isDark(d,e+2)&&a.isDark(d,e+3)&&a.isDark(d,e+4)&&!a.isDark(d,e+5)&&a.isDark(d,e+6)&&(c+=40);for(var e=0;b>e;e++)for(var d=0;b-6>d;d++)a.isDark(d,e)&&!a.isDark(d+1,e)&&a.isDark(d+2,e)&&a.isDark(d+3,e)&&a.isDark(d+4,e)&&!a.isDark(d+5,e)&&a.isDark(d+6,e)&&(c+=40);for(var k=0,e=0;b>e;e++)for(var d=0;b>d;d++)a.isDark(d,e)&&k++;var l=Math.abs(100*k/b/b-50)/5;return c+=10*l}},g={glog:function(a){if(1>a)throw new Error("glog("+a+")");return g.LOG_TABLE[a]},gexp:function(a){for(;0>a;)a+=255;for(;a>=256;)a-=255;return g.EXP_TABLE[a]},EXP_TABLE:new Array(256),LOG_TABLE:new Array(256)},h=0;8>h;h++)g.EXP_TABLE[h]=1<h;h++)g.EXP_TABLE[h]=g.EXP_TABLE[h-4]^g.EXP_TABLE[h-5]^g.EXP_TABLE[h-6]^g.EXP_TABLE[h-8];for(var h=0;255>h;h++)g.LOG_TABLE[g.EXP_TABLE[h]]=h;i.prototype={get:function(a){return this.num[a]},getLength:function(){return this.num.length},multiply:function(a){for(var b=new Array(this.getLength()+a.getLength()-1),c=0;cf;f++)for(var g=c[3*f+0],h=c[3*f+1],i=c[3*f+2],k=0;g>k;k++)e.push(new j(h,i));return e},j.getRsBlockTable=function(a,b){switch(b){case d.L:return j.RS_BLOCK_TABLE[4*(a-1)+0];case d.M:return j.RS_BLOCK_TABLE[4*(a-1)+1];case d.Q:return j.RS_BLOCK_TABLE[4*(a-1)+2];case d.H:return j.RS_BLOCK_TABLE[4*(a-1)+3];default:return void 0}},k.prototype={get:function(a){var b=Math.floor(a/8);return 1==(1&this.buffer[b]>>>7-a%8)},put:function(a,b){for(var c=0;b>c;c++)this.putBit(1==(1&a>>>b-c-1))},getLengthInBits:function(){return this.length},putBit:function(a){var b=Math.floor(this.length/8);this.buffer.length<=b&&this.buffer.push(0),a&&(this.buffer[b]|=128>>>this.length%8),this.length++}};var l=[[17,14,11,7],[32,26,20,14],[53,42,32,24],[78,62,46,34],[106,84,60,44],[134,106,74,58],[154,122,86,64],[192,152,108,84],[230,180,130,98],[271,213,151,119],[321,251,177,137],[367,287,203,155],[425,331,241,177],[458,362,258,194],[520,412,292,220],[586,450,322,250],[644,504,364,280],[718,560,394,310],[792,624,442,338],[858,666,482,382],[929,711,509,403],[1003,779,565,439],[1091,857,611,461],[1171,911,661,511],[1273,997,715,535],[1367,1059,751,593],[1465,1125,805,625],[1528,1190,868,658],[1628,1264,908,698],[1732,1370,982,742],[1840,1452,1030,790],[1952,1538,1112,842],[2068,1628,1168,898],[2188,1722,1228,958],[2303,1809,1283,983],[2431,1911,1351,1051],[2563,1989,1423,1093],[2699,2099,1499,1139],[2809,2213,1579,1219],[2953,2331,1663,1273]],o=function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){function g(a,b){var c=document.createElementNS("http://www.w3.org/2000/svg",a);for(var d in b)b.hasOwnProperty(d)&&c.setAttribute(d,b[d]);return c}var b=this._htOption,c=this._el,d=a.getModuleCount();Math.floor(b.width/d),Math.floor(b.height/d),this.clear();var h=g("svg",{viewBox:"0 0 "+String(d)+" "+String(d),width:"100%",height:"100%",fill:b.colorLight});h.setAttributeNS("http://www.w3.org/2000/xmlns/","xmlns:xlink","http://www.w3.org/1999/xlink"),c.appendChild(h),h.appendChild(g("rect",{fill:b.colorDark,width:"1",height:"1",id:"template"}));for(var i=0;d>i;i++)for(var j=0;d>j;j++)if(a.isDark(i,j)){var k=g("use",{x:String(i),y:String(j)});k.setAttributeNS("http://www.w3.org/1999/xlink","href","#template"),h.appendChild(k)}},a.prototype.clear=function(){for(;this._el.hasChildNodes();)this._el.removeChild(this._el.lastChild)},a}(),p="svg"===document.documentElement.tagName.toLowerCase(),q=p?o:m()?function(){function a(){this._elImage.src=this._elCanvas.toDataURL("image/png"),this._elImage.style.display="block",this._elCanvas.style.display="none"}function d(a,b){var c=this;if(c._fFail=b,c._fSuccess=a,null===c._bSupportDataURI){var d=document.createElement("img"),e=function(){c._bSupportDataURI=!1,c._fFail&&_fFail.call(c)},f=function(){c._bSupportDataURI=!0,c._fSuccess&&c._fSuccess.call(c)};return d.onabort=e,d.onerror=e,d.onload=f,d.src="data:image/gif;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==",void 0}c._bSupportDataURI===!0&&c._fSuccess?c._fSuccess.call(c):c._bSupportDataURI===!1&&c._fFail&&c._fFail.call(c)}if(this._android&&this._android<=2.1){var b=1/window.devicePixelRatio,c=CanvasRenderingContext2D.prototype.drawImage;CanvasRenderingContext2D.prototype.drawImage=function(a,d,e,f,g,h,i,j){if("nodeName"in a&&/img/i.test(a.nodeName))for(var l=arguments.length-1;l>=1;l--)arguments[l]=arguments[l]*b;else"undefined"==typeof j&&(arguments[1]*=b,arguments[2]*=b,arguments[3]*=b,arguments[4]*=b);c.apply(this,arguments)}}var e=function(a,b){this._bIsPainted=!1,this._android=n(),this._htOption=b,this._elCanvas=document.createElement("canvas"),this._elCanvas.width=b.width,this._elCanvas.height=b.height,a.appendChild(this._elCanvas),this._el=a,this._oContext=this._elCanvas.getContext("2d"),this._bIsPainted=!1,this._elImage=document.createElement("img"),this._elImage.style.display="none",this._el.appendChild(this._elImage),this._bSupportDataURI=null};return e.prototype.draw=function(a){var b=this._elImage,c=this._oContext,d=this._htOption,e=a.getModuleCount(),f=d.width/e,g=d.height/e,h=Math.round(f),i=Math.round(g);b.style.display="none",this.clear();for(var j=0;e>j;j++)for(var k=0;e>k;k++){var l=a.isDark(j,k),m=k*f,n=j*g;c.strokeStyle=l?d.colorDark:d.colorLight,c.lineWidth=1,c.fillStyle=l?d.colorDark:d.colorLight,c.fillRect(m,n,f,g),c.strokeRect(Math.floor(m)+.5,Math.floor(n)+.5,h,i),c.strokeRect(Math.ceil(m)-.5,Math.ceil(n)-.5,h,i)}this._bIsPainted=!0},e.prototype.makeImage=function(){this._bIsPainted&&d.call(this,a)},e.prototype.isPainted=function(){return this._bIsPainted},e.prototype.clear=function(){this._oContext.clearRect(0,0,this._elCanvas.width,this._elCanvas.height),this._bIsPainted=!1},e.prototype.round=function(a){return a?Math.floor(1e3*a)/1e3:a},e}():function(){var a=function(a,b){this._el=a,this._htOption=b};return a.prototype.draw=function(a){for(var b=this._htOption,c=this._el,d=a.getModuleCount(),e=Math.floor(b.width/d),f=Math.floor(b.height/d),g=[''],h=0;d>h;h++){g.push("");for(var i=0;d>i;i++)g.push('');g.push("")}g.push("
    "),c.innerHTML=g.join("");var j=c.childNodes[0],k=(b.width-j.offsetWidth)/2,l=(b.height-j.offsetHeight)/2;k>0&&l>0&&(j.style.margin=l+"px "+k+"px")},a.prototype.clear=function(){this._el.innerHTML=""},a}();QRCode=function(a,b){if(this._htOption={width:256,height:256,typeNumber:4,colorDark:"#000000",colorLight:"#ffffff",correctLevel:d.H},"string"==typeof b&&(b={text:b}),b)for(var c in b)this._htOption[c]=b[c];"string"==typeof a&&(a=document.getElementById(a)),this._android=n(),this._el=a,this._oQRCode=null,this._oDrawing=new q(this._el,this._htOption),this._htOption.text&&this.makeCode(this._htOption.text)},QRCode.prototype.makeCode=function(a){this._oQRCode=new b(r(a,this._htOption.correctLevel),this._htOption.correctLevel),this._oQRCode.addData(a),this._oQRCode.make(),this._el.title=a,this._oDrawing.draw(this._oQRCode),this.makeImage()},QRCode.prototype.makeImage=function(){"function"==typeof this._oDrawing.makeImage&&(!this._android||this._android>=3)&&this._oDrawing.makeImage()},QRCode.prototype.clear=function(){this._oDrawing.clear()},QRCode.CorrectLevel=d}(); -------------------------------------------------------------------------------- /src/frontend/www/loading.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Loading 6 | 7 | 8 | 9 |
    10 |
    11 |
    12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    18 |
    19 |
    20 |
    21 |
    22 |
    23 |
    24 |
    25 |
    26 |
    27 | 28 | 29 | -------------------------------------------------------------------------------- /src/frontend/www/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 登录微信读书 7 | 8 | 9 | 10 | 11 |

    微信扫码登录

    12 | 13 |
    14 |
    15 |
    16 | 17 | 18 | 19 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/frontend/www/read.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 自动阅读 7 | 8 | 9 | 129 | 130 | 131 | 132 | 148 | 149 |
    150 |
    151 |
    152 |

    读书排行榜

    153 |
    154 |
    155 | 156 |
    157 |
    158 |

    “自动阅读”功能说明

    159 |

    1. 该功能是为了实现微信读书的 自动阅读,但是不需要打开 APP 或网页,而是通过服务器后台每隔30分钟更新一次阅读进度。

    160 |

    2. 注意:该功能并不会更新真实的阅读进度,只会更新阅读时长,可用于刷“读书排行榜”或者“阅读挑战赛”。也就是说,添加到这里的书并不会被自动读完。

    161 |

    3. 你可以在书籍详情页将书籍加入到“自动阅读”中,服务器会自动更新这本书的阅读时长。

    162 |

    4. 每个用户只能添加一本书,当添加第二本时会自动把之前的自动阅读任务取消。

    163 |

    5. 该功能目前免费,但是考虑到服务器成本(主要是流量以及kv操作),后续根据情况可能会降低更新频率或者限制每天只更新3个小时这样。

    164 |
    165 |
    166 |
    167 |

    正在自动阅读:

    168 | 169 |
    170 |
    171 |

    172 | 书名: 173 | 174 |

    175 |

    176 | 作者: 177 | 178 |

    179 |

    180 | 开始阅读时间: 181 | 182 |

    183 |

    184 | 最后阅读时间: 185 | 186 |

    187 |

    188 | 今日阅读时长: 189 | 190 |

    191 |
    192 |

    当前还没有自动阅读任务~

    193 |
    194 |
    195 |
    196 |
    197 | 198 | 209 | 210 | 357 | 358 | 359 | -------------------------------------------------------------------------------- /src/frontend/www/review.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 我的笔记 7 | 8 | 9 | 10 | 11 |
    12 | 13 |
    14 |
    15 |
    16 |
    第1章 权衡的艺术
    17 |
    19 |
    20 |
    21 | 纯运行时,就是用户写的代码可以直接运行,中间不需要任何操作。而运行时+编译时,就是用户代码在运行之前还需要进一步处理,不能直接运行,运行之前的这个处理,就是编译过程 22 |
    23 |
    实际上,我们刚刚编写的框架就是一个纯运行时的框架。
    24 |
    25 |
    26 |
    27 |
    28 |
    第4章 响应系统的作用与实现
    29 |
    31 |
    32 |
    33 | 这里创建包装函数的目的是因为我们想要在副作用函数上保存一些数据,比如这里的deps数组,但是我们不能直接保存到传进来的fn上面,因为这个fn是用户的函数,我们不能污染这个函数,所以这里创建了一个wrapper函数,我们把deps数据保存到这个wrapper函数上面,这样就不会无意间覆盖用户代码上的数据。 34 |
    35 |
    在 effect 内部我们定义了新的 effectFn 函数
    36 |
    37 |
    38 |
    39 |
    40 |
    41 |
    43 |
    44 |
    45 | 这里应该用Set会更好吧,要不然在同一个副作用函数中多次访问同一个响应式对象下的同一个属性,会导致这个数组存储多份相同的dep依赖集合。 46 |
    47 |
    该属性是一个数组
    48 |
    49 |
    50 |
    51 |
    52 |
    53 |
    55 |
    56 |
    https://tc39.es/ecma262/#sec-set.prototype.foreach 57 | 在备注里面有说明,正常情况下,集合里面的元素只会被访问(遍历)一次,但如果该元素被访问之后,先从集合里面删除,然后在整个集合完成遍历之前又被添加到集合里面,则这个元素还会被再次访问到。 58 | 59 | 注意,这个操作并没有被局限在一个回调函数之内,也就是说,在整个forEach遍历过程中,只要满足先删除,再添加,则这个元素都会被访问多次。 60 |
    61 |
    明确的说明
    62 |
    63 |
    64 |
    65 |
    66 |
    第5章 非原始值的响应式方案
    67 |
    69 |
    70 |
    这里是错误的,receiver只有在访问的是getter或者setter时才有效,所以这里打印的还是1
    71 |
    这时读取到的值是 receiver 对象的 foo 属性值
    72 |
    73 |
    74 |
    75 |
    76 |
    77 |
    79 |
    80 |
    这里如果是用Object.keys获取属性列表,可以被拦截到嘛
    81 |
    下面列出了对一个普通对象的所有可能的读取操作。 82 | ● 访问属性:obj.foo。 83 | ● 判断对象或原型上是否存在给定的 key:key in obj。 84 | ● 使用 for...in 循环遍历对象:for (const key in obj){}。 85 |
    86 |
    87 |
    88 |
    89 |
    90 |
    第13章 异步组件与函数式组件
    91 |
    93 |
    94 |
    这种方式有个缺点,就是一旦观察了错误对象就无法再重试了。 95 | 比如,对于网络错误我想要重试,但是对于其他错误,重试没有意义,所以我需要观察这个错误的类型 96 |
    97 |
    下面的代码展示了用户是如何进行重试加载的
    98 |
    99 |
    100 |
    101 |
    102 |
    103 | 104 | 149 | 150 | 151 | -------------------------------------------------------------------------------- /src/frontend/www/search.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 搜索 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 30 | 31 |
    32 |
    33 |
    34 | 35 |
    36 | 38 |
    39 | 55 | 56 |
    57 |
    58 |
    59 | 60 | 71 | 72 | 121 | 122 | 123 | -------------------------------------------------------------------------------- /src/frontend/www/style/common.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-size: 12px; 3 | line-height: 1.5; 4 | background: #eee; 5 | font-family: PingFang SC, -apple-system, SF UI Text, Lucida Grande, STheiti, Microsoft YaHei, sans-serif; 6 | outline: none; 7 | -webkit-text-size-adjust: none; 8 | } 9 | 10 | .navBar { 11 | margin-bottom: 10px; 12 | position: sticky; 13 | top: 0; 14 | z-index: 200; 15 | background-color: rgba(255, 255, 255, 0.9); 16 | --un-backdrop-blur: blur(8px); 17 | -webkit-backdrop-filter: var(--un-backdrop-blur); 18 | backdrop-filter: var(--un-backdrop-blur); 19 | box-shadow: rgba(0, 0, 0, 0) 0 0 0 0, 20 | rgba(0, 0, 0, 0) 0 0 0 0, 21 | rgba(0, 0, 0, 0.1) 0 4px 6px -1px, 22 | rgba(0, 0, 0, 0.1) 0 2px 4px -2px; 23 | } 24 | 25 | .navBar_inner { 26 | display: flex; 27 | justify-content: space-between; 28 | align-items: center; 29 | max-width: 1200px; 30 | margin: 0 auto; 31 | height: 60px; 32 | } 33 | 34 | @media (max-width: 460px) { 35 | .navBar_inner { 36 | padding: 10px 0 6px; 37 | } 38 | } 39 | 40 | .navBar_title { 41 | font-size: 20px; 42 | font-family: "SourceHanSerifCN-Bold", PingFang SC, -apple-system, SF UI Text, Lucida Grande, STheiti, Microsoft YaHei, sans-serif; 43 | color: #212832; 44 | 45 | .total { 46 | font-size: 14px; 47 | } 48 | } 49 | 50 | .navBar_menu { 51 | display: flex; 52 | align-items: center; 53 | } 54 | 55 | .navBar_link { 56 | margin-left: 12px; 57 | font-size: 16px; 58 | font-weight: 500; 59 | opacity: .5; 60 | color: #0d141e; 61 | text-decoration: none; 62 | -webkit-tap-highlight-color: rgba(0, 0, 0, .03); 63 | user-select: none; 64 | 65 | &:hover { 66 | opacity: 1; 67 | } 68 | &.current { 69 | opacity: 1; 70 | } 71 | } 72 | 73 | .navBar_separator { 74 | display: block; 75 | width: 1px; 76 | height: 16px; 77 | margin-left: 18px; 78 | opacity: .3; 79 | background-color: #0d141e; 80 | } 81 | 82 | .feedback { 83 | position: fixed; 84 | right: 2rem; 85 | bottom: 2rem; 86 | z-index: 1000; 87 | font-size: 1.3rem; 88 | color: #007fff; 89 | transition: none; 90 | margin: 1rem 0 0; 91 | padding: 0; 92 | width: 3.33rem; 93 | height: 3.33rem; 94 | line-height: 1; 95 | background-color: white; 96 | border-radius: 50%; 97 | box-shadow: 0 2px 8px rgba(50,50,50,.04); 98 | display: flex; 99 | align-items: center; 100 | justify-content: center; 101 | cursor: pointer; 102 | } 103 | 104 | @media (max-width: 460px) { 105 | .feedback { 106 | display: none; 107 | } 108 | } 109 | 110 | 111 | .page_container { 112 | max-width: 1200px; 113 | margin: 0 auto; 114 | } 115 | 116 | .hide_scrollbar { 117 | scrollbar-width: none; /* Firefox */ 118 | -ms-overflow-style: none; /* IE 10+ */ 119 | &::-webkit-scrollbar { 120 | display: none; /* Chrome Safari */ 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/frontend/www/style/detail.css: -------------------------------------------------------------------------------- 1 | .readerBookInfo { 2 | border-radius: 0; 3 | border: solid rgba(0, 0, 0, .05); 4 | border-width: 0 0 1px; 5 | background-color: #FFF; 6 | padding: 0 36px; 7 | } 8 | 9 | .readerBookInfo_head { 10 | display: flex; 11 | padding-top: 40px; 12 | background: white; 13 | } 14 | 15 | @media (max-width: 460px) { 16 | .readerBookInfo_head { 17 | padding-top: 20px 18 | } 19 | } 20 | 21 | .readerBookInfo_head .bookInfo_cover { 22 | flex-shrink: 0; 23 | width: 160px; 24 | height: 232px 25 | } 26 | 27 | @media (max-width: 1365px) { 28 | .readerBookInfo_head .bookInfo_cover { 29 | width: 134px; 30 | height: 195px 31 | } 32 | } 33 | 34 | @media (max-width: 1023px) { 35 | .readerBookInfo_head .bookInfo_cover { 36 | width: 108px; 37 | height: 157px 38 | } 39 | } 40 | 41 | @media (max-width: 460px) { 42 | .readerBookInfo_head .bookInfo_cover { 43 | width: 108px; 44 | height: 157px 45 | } 46 | } 47 | 48 | .bookInfo_right { 49 | flex: 1; 50 | margin-left: 40px 51 | } 52 | 53 | @media (max-width: 1023px) { 54 | .bookInfo_right { 55 | margin-left: 20px 56 | } 57 | } 58 | 59 | @media (max-width: 460px) { 60 | .bookInfo_right { 61 | margin-left: 20px; 62 | padding-top: 0 63 | } 64 | } 65 | 66 | .bookInfo_right_header { 67 | display: flex; 68 | justify-content: space-between 69 | } 70 | 71 | .bookInfo_right_header_title { 72 | margin-right: 2rem; 73 | } 74 | 75 | .bookInfo_right_header_title_text { 76 | margin-top: 4px; 77 | color: #212832; 78 | font-family: "SourceHanSerifCN-Bold", PingFang SC, -apple-system, SF UI Text, Lucida Grande, STheiti, Microsoft YaHei, sans-serif; 79 | font-size: 24px; 80 | line-height: 34px; 81 | font-weight: 500 82 | } 83 | 84 | @media (max-width: 1023px) { 85 | .bookInfo_right_header_title_text { 86 | font-size: 20px; 87 | line-height: 31px 88 | } 89 | } 90 | 91 | @media (max-width: 460px) { 92 | .bookInfo_right_header_title_text { 93 | margin-top: 0; 94 | font-size: 18px; 95 | line-height: 29px 96 | } 97 | } 98 | 99 | .bookInfo_right_header_title_action_wrapper { 100 | display: flex; 101 | flex-direction: row 102 | } 103 | 104 | @media (max-width: 1365px) { 105 | .bookInfo_right_header_title_action_wrapper { 106 | display: block 107 | } 108 | } 109 | 110 | .btn_primary { 111 | flex-shrink: 0; 112 | display: flex; 113 | padding: 0 25px; 114 | justify-content: center; 115 | align-items: center; 116 | height: 40px; 117 | line-height: 40px; 118 | border-radius: 20px; 119 | font-size: 14px; 120 | font-weight: 500; 121 | background-image: linear-gradient(90deg, #0087fc, #28b7ff); 122 | color: #fff; 123 | 124 | &[disabled] { 125 | background-color: rgba(13, 20, 30, .04); 126 | color: #717882; 127 | cursor: not-allowed; 128 | background-image: none; 129 | } 130 | } 131 | .download { 132 | margin-right: 30px; 133 | } 134 | 135 | .origin-download { 136 | color: #0087fc; 137 | cursor: pointer; 138 | display: none; 139 | } 140 | 141 | .bookInfo_author_container { 142 | margin-top: 2px 143 | } 144 | 145 | .bookInfo_author { 146 | color: #5d646e; 147 | font-family: "SourceHanSerifCN-Bold", PingFang SC, -apple-system, SF UI Text, Lucida Grande, STheiti, Microsoft YaHei, sans-serif; 148 | font-size: 18px; 149 | line-height: 26px 150 | } 151 | 152 | .bookInfo_intro { 153 | position: relative; 154 | margin-top: 21px; 155 | text-align: justify; 156 | color: #5d646e; 157 | line-height: 25px; 158 | overflow: hidden; 159 | height: 100px; 160 | display: -webkit-box; 161 | display: -moz-box; 162 | text-overflow: ellipsis; 163 | -webkit-line-clamp: 4; 164 | -moz-line-clamp: 4; 165 | line-clamp: 4; 166 | -webkit-box-orient: vertical; 167 | -webkit-text-size-adjust: none; 168 | box-orient: vertical; 169 | font-size: 14px; 170 | } 171 | 172 | .introDialogWrap { 173 | max-height: 80%; 174 | overflow: auto; 175 | padding-bottom: 50px; 176 | } 177 | 178 | .introDialog_content { 179 | min-width: 300px; 180 | padding: 20px; 181 | text-align: left; 182 | color: #b2b4b8; 183 | font-size: 14px 184 | } 185 | 186 | .wr_whiteTheme .introDialog_content { 187 | color: #5d646e 188 | } 189 | 190 | .introDialog_content_title { 191 | font-family: "SourceHanSerifCN-Bold", PingFang SC, -apple-system, SF UI Text, Lucida Grande, STheiti, Microsoft YaHei, sans-serif; 192 | font-size: 18px; 193 | color: #eef0f4; 194 | margin-bottom: 16px 195 | } 196 | 197 | .wr_whiteTheme .introDialog_content_title { 198 | color: #212832 199 | } 200 | 201 | .introDialog_content_title:not(:first-child) { 202 | margin-top: 32px 203 | } 204 | 205 | .introDialog_content_intro_para { 206 | line-height: 24px; 207 | text-align: justify 208 | } 209 | 210 | .introDialog_content_intro_para:not(:first-child) { 211 | margin-top: 12px 212 | } 213 | 214 | .introDialog_content_pub_line { 215 | display: flex; 216 | justify-content: space-between 217 | } 218 | 219 | .introDialog_content_pub_line:not(:last-child) { 220 | margin-bottom: 12px 221 | } 222 | 223 | .introDialog_content_pub_line.long { 224 | max-width: 100% 225 | } 226 | 227 | .introDialog_content_pub_title { 228 | color: #8a8c90 229 | } 230 | 231 | .wr_whiteTheme .introDialog_content_pub_title { 232 | color: #858c96 233 | } 234 | 235 | button { 236 | background: none; 237 | border: 0; 238 | padding: 0; 239 | text-decoration: none; 240 | cursor: pointer 241 | } 242 | 243 | .chapters { 244 | list-style-type: none; 245 | padding-left: 0; 246 | .chapter { 247 | &[data-level="1"] { 248 | padding-left: 0; 249 | } 250 | &[data-level="2"] { 251 | padding-left: 2rem; 252 | } 253 | &[data-level="3"] { 254 | padding-left: 4rem; 255 | } 256 | &[data-level="4"] { 257 | padding-left: 6rem; 258 | } 259 | &[data-level="5"] { 260 | padding-left: 8rem; 261 | } 262 | &[data-level="6"] { 263 | padding-left: 10rem; 264 | } 265 | &[data-level="7"] { 266 | padding-left: 10rem; 267 | } 268 | &[data-level="8"] { 269 | padding-left: 10rem; 270 | } 271 | &[data-level="9"] { 272 | padding-left: 10rem; 273 | } 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /src/frontend/www/style/index.css: -------------------------------------------------------------------------------- 1 | .wr_whiteTheme .bookshelf_preview_item { 2 | /*box-shadow: 0 0 30px rgba(0, 0, 0, .04)*/ 3 | box-shadow: #fff 0 0 0 0, rgb(229, 231, 235) 0 0 0 1px, rgba(0, 0, 0, 0) 0 0 0 0; 4 | } 5 | 6 | .bookshelf_preview_item { 7 | position: relative; 8 | height: 169px; 9 | overflow: hidden; 10 | border-radius: 12px; 11 | width: 23.5%; 12 | transition: all .2s ease-in-out; 13 | background-color: #1c1c1d 14 | } 15 | 16 | .wr_whiteTheme .bookshelf_preview_item { 17 | background-color: #fff; 18 | margin-bottom: 30px; 19 | } 20 | 21 | @media (max-width: 1200px) { 22 | .bookshelf_preview_item { 23 | height: 138px 24 | } 25 | } 26 | 27 | @media (max-width: 960px) { 28 | .bookshelf_preview_item { 29 | width: 48%; 30 | } 31 | 32 | .bookshelf_preview_item:first-child, .bookshelf_preview_item:nth-child(2) { 33 | margin-top: 0 34 | } 35 | } 36 | 37 | @media (max-width: 720px) { 38 | .bookshelf_preview_item { 39 | width: 100%; 40 | margin-top: 20px 41 | } 42 | 43 | .bookshelf_preview_item:nth-child(2) { 44 | margin-top: 20px 45 | } 46 | } 47 | 48 | @media (max-width: 460px) { 49 | .bookshelf_preview_item { 50 | height: auto; 51 | margin-top: 0 !important; 52 | padding: 10px 0; 53 | border-radius: 0; 54 | } 55 | 56 | .bookshelf_preview_item, .wr_whiteTheme .bookshelf_preview_item { 57 | background-color: transparent 58 | } 59 | 60 | .bookshelf_preview_item:nth-child(-n+3) { 61 | display: block 62 | } 63 | 64 | .bookshelf_preview_item:nth-child(3) { 65 | border-radius: 0; 66 | border: 0 solid hsla(0, 0%, 100%, .05) 67 | } 68 | } 69 | 70 | @media (max-width: 460px)and (-webkit-min-device-pixel-ratio: 2),(max-width: 460px)and (min-device-pixel-ratio: 2),(max-width: 460px)and (min-resolution: 2dppx),(max-width: 460px)and (min-resolution: 192dpi) { 71 | .bookshelf_preview_item:nth-child(3) { 72 | position: relative; 73 | border: 0 74 | } 75 | 76 | .bookshelf_preview_item:nth-child(3):after { 77 | content: ""; 78 | position: absolute; 79 | top: 0; 80 | left: 0; 81 | width: 200%; 82 | height: 200%; 83 | border-radius: 0; 84 | border: 0 solid hsla(0, 0%, 100%, .05); 85 | transform: scale(.5); 86 | transform-origin: 0 0; 87 | pointer-events: none 88 | } 89 | } 90 | 91 | @media (max-width: 460px)and (-webkit-min-device-pixel-ratio: 3),(max-width: 460px)and (min-device-pixel-ratio: 3),(max-width: 460px)and (min-resolution: 3dppx),(max-width: 460px)and (min-resolution: 288dpi) { 92 | .bookshelf_preview_item:nth-child(3):after { 93 | width: 300%; 94 | height: 300%; 95 | border-radius: 0; 96 | transform: scale(.3333333333) 97 | } 98 | } 99 | 100 | .bookshelf_preview_item:last-child { 101 | margin-right: 0 102 | } 103 | 104 | .bookshelf_preview_item:hover { 105 | transform: scale(1.1) 106 | } 107 | 108 | .bookshelf_preview_item:hover .bookshelf_preview_title { 109 | color: #eef0f4 110 | } 111 | 112 | .wr_whiteTheme .bookshelf_preview_item:hover .bookshelf_preview_title { 113 | color: #212832 114 | } 115 | 116 | 117 | .bookshelf_preview_item_container { 118 | display: table; 119 | padding: 24px; 120 | box-sizing: border-box 121 | } 122 | 123 | .bookshelf_preview_item_link { 124 | position: absolute; 125 | top: 0; 126 | width: 100%; 127 | height: 100%; 128 | z-index: 1; 129 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0) !important 130 | } 131 | 132 | .bookshelf_preview_item .bookshelf_preview_cover { 133 | display: table-cell; 134 | text-align: left; 135 | vertical-align: middle; 136 | width: 84px; 137 | height: 121px 138 | } 139 | 140 | .bookshelf_preview_content { 141 | padding: 0 0 0 24px; 142 | display: table-cell; 143 | vertical-align: middle 144 | } 145 | 146 | .bookshelf_preview_title { 147 | font-size: 16px; 148 | font-family: "SourceHanSerifCN-Bold", PingFang SC, -apple-system, SF UI Text, Lucida Grande, STheiti, Microsoft YaHei, sans-serif; 149 | color: #eef0f4; 150 | line-height: 24px; 151 | overflow: hidden; 152 | height: 48px; 153 | display: -webkit-box; 154 | display: -moz-box; 155 | text-overflow: ellipsis; 156 | -webkit-line-clamp: 2; 157 | -moz-line-clamp: 2; 158 | line-clamp: 2; 159 | -webkit-box-orient: vertical; 160 | -webkit-text-size-adjust: none; 161 | box-orient: vertical; 162 | height: auto; 163 | max-height: 48px; 164 | word-break: break-all 165 | } 166 | 167 | .wr_whiteTheme .bookshelf_preview_title { 168 | color: #0d141e 169 | } 170 | 171 | .bookshelf_preview_author { 172 | position: relative; 173 | display: inline-block !important; 174 | margin-top: 8px; 175 | font-size: 14px; 176 | vertical-align: bottom; 177 | color: #8a8c90; 178 | line-height: 22px; 179 | overflow: hidden; 180 | height: 22px; 181 | display: -webkit-box; 182 | display: -moz-box; 183 | text-overflow: ellipsis; 184 | -webkit-line-clamp: 1; 185 | -moz-line-clamp: 1; 186 | line-clamp: 1; 187 | -webkit-box-orient: vertical; 188 | -webkit-text-size-adjust: none; 189 | box-orient: vertical; 190 | height: auto; 191 | max-height: 22px 192 | } 193 | 194 | .wr_whiteTheme .bookshelf_preview_author { 195 | color: #858c96 196 | } 197 | 198 | @media (max-width: 1200px) { 199 | .bookshelf_preview_item .bookshelf_preview_cover { 200 | width: 68px; 201 | height: 98px 202 | } 203 | 204 | .bookshelf_preview_content { 205 | padding-left: 18px 206 | } 207 | 208 | .bookshelf_preview_title { 209 | font-size: 15px; 210 | word-break: break-all 211 | } 212 | 213 | .bookshelf_preview_author { 214 | font-size: 13px; 215 | margin-top: 6px 216 | } 217 | 218 | .bookshelf_preview_item_container { 219 | padding: 20px 220 | } 221 | } 222 | 223 | @media (max-width: 460px) { 224 | .bookshelf_preview_item .bookshelf_preview_cover { 225 | width: 56px; 226 | height: 81px 227 | } 228 | 229 | .bookshelf_preview_title { 230 | font-family: PingFang SC, -apple-system, SF UI Text, Lucida Grande, STheiti, Microsoft YaHei, sans-serif; 231 | font-weight: 700 232 | } 233 | 234 | .bookshelf_preview_author { 235 | font-size: 12px; 236 | margin-top: 2px 237 | } 238 | 239 | .bookshelf_preview_item_container { 240 | padding: 0 241 | } 242 | } 243 | 244 | 245 | .bookshelf_preview_container { 246 | margin-bottom: 40px 247 | } 248 | 249 | @media (max-width: 1200px) { 250 | .bookshelf_preview_container { 251 | padding: 0 40px 252 | } 253 | } 254 | 255 | @media (max-width: 960px) { 256 | .bookshelf_preview_container { 257 | margin-bottom: 32px; 258 | padding: 0 30px 259 | } 260 | } 261 | 262 | @media (max-width: 460px) { 263 | .bookshelf_preview_container { 264 | margin-bottom: 28px; 265 | padding: 0 20px 266 | } 267 | } 268 | 269 | .bookshelf_preview_body { 270 | display: flex; 271 | flex-wrap: wrap; 272 | justify-content: space-between 273 | } 274 | -------------------------------------------------------------------------------- /src/frontend/www/style/loading.css: -------------------------------------------------------------------------------- 1 | .readerChapterContentLoading { 2 | position: relative; 3 | height: 80px; 4 | margin-top: 20%; 5 | } 6 | 7 | .readerChapterContentLoading .wr_loading { 8 | left: 50%; 9 | top: 40%; 10 | } 11 | 12 | .wr_loading { 13 | position: absolute; 14 | } 15 | 16 | .wr_loading_line { 17 | position: relative; 18 | } 19 | 20 | @keyframes wr_loading { 21 | 0% { 22 | opacity: 1 23 | } 24 | 25 | to { 26 | opacity: .25 27 | } 28 | } 29 | 30 | .wr_loading_line div { 31 | width: 5px; 32 | height: 20px; 33 | position: absolute; 34 | left: 100%; 35 | top: 100%; 36 | animation: wr_loading 1.2s linear infinite; 37 | background: #B2B4B8; 38 | } 39 | 40 | .wr_loading_line .wr_loading_line_1 { 41 | transform: rotate(0deg) translateY(-34px); 42 | animation-delay: 0s; 43 | opacity: 0 44 | } 45 | 46 | .wr_loading_line .wr_loading_line_2 { 47 | transform: rotate(30deg) translateY(-34px); 48 | animation-delay: .1s; 49 | opacity: .0833333333 50 | } 51 | 52 | .wr_loading_line .wr_loading_line_3 { 53 | transform: rotate(60deg) translateY(-34px); 54 | animation-delay: .2s; 55 | opacity: .1666666667 56 | } 57 | 58 | .wr_loading_line .wr_loading_line_4 { 59 | transform: rotate(90deg) translateY(-34px); 60 | animation-delay: .3s; 61 | opacity: .25 62 | } 63 | 64 | .wr_loading_line .wr_loading_line_5 { 65 | transform: rotate(120deg) translateY(-34px); 66 | animation-delay: .4s; 67 | opacity: .3333333333 68 | } 69 | 70 | .wr_loading_line .wr_loading_line_6 { 71 | transform: rotate(150deg) translateY(-34px); 72 | animation-delay: .5s; 73 | opacity: .4166666667 74 | } 75 | 76 | .wr_loading_line .wr_loading_line_7 { 77 | transform: rotate(180deg) translateY(-34px); 78 | animation-delay: .6s; 79 | opacity: .5 80 | } 81 | 82 | .wr_loading_line .wr_loading_line_8 { 83 | transform: rotate(210deg) translateY(-34px); 84 | animation-delay: .7s; 85 | opacity: .5833333333 86 | } 87 | 88 | .wr_loading_line .wr_loading_line_9 { 89 | transform: rotate(240deg) translateY(-34px); 90 | animation-delay: .8s; 91 | opacity: .6666666667 92 | } 93 | 94 | .wr_loading_line .wr_loading_line_10 { 95 | transform: rotate(270deg) translateY(-34px); 96 | animation-delay: .9s; 97 | opacity: .75 98 | } 99 | 100 | .wr_loading_line .wr_loading_line_11 { 101 | transform: rotate(300deg) translateY(-34px); 102 | animation-delay: 1s; 103 | opacity: .8333333333 104 | } 105 | 106 | .wr_loading_line .wr_loading_line_12 { 107 | transform: rotate(330deg) translateY(-34px); 108 | animation-delay: 1.1s; 109 | opacity: .9166666667 110 | } 111 | -------------------------------------------------------------------------------- /src/frontend/www/style/login.css: -------------------------------------------------------------------------------- 1 | body { 2 | max-width: 800px; 3 | margin: 0 auto; 4 | text-align: center; 5 | } 6 | 7 | h1 { 8 | margin: 2rem; 9 | } 10 | 11 | #qrcode { 12 | display: block; 13 | width: 300px; 14 | height: 300px; 15 | margin: 0 auto; 16 | transition: opacity .2s; 17 | } 18 | 19 | .expired { 20 | position: relative; 21 | 22 | #qrcode { 23 | opacity: .1; 24 | } 25 | 26 | &::after { 27 | position: absolute; 28 | top: 50%; 29 | left: 50%; 30 | transform: translateX(-50%) translateY(-50%); 31 | content: "已过期"; 32 | color: red; 33 | font-size: 30px; 34 | } 35 | } 36 | 37 | button { 38 | --primary-color: rgba(2, 107, 235, 1); 39 | margin-top: 30px; 40 | color: rgba(255, 255, 255, 1); 41 | padding: 0 1.125rem; 42 | background-color: var(--primary-color); 43 | border-radius: 0.375rem; 44 | border: 1px solid var(--primary-color); 45 | cursor: pointer; 46 | height: 2.25rem; 47 | transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1); 48 | 49 | &:hover { 50 | color: var(--primary-color); 51 | background-color: rgba(255, 255, 255, 1); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/frontend/www/style/reset.css: -------------------------------------------------------------------------------- 1 | /* 2 | Josh's Custom CSS Reset 3 | https://www.joshwcomeau.com/css/custom-css-reset/ 4 | */ 5 | *, *::before, *::after { 6 | box-sizing: border-box; 7 | } 8 | * { 9 | margin: 0; 10 | } 11 | body { 12 | line-height: 1.5; 13 | -webkit-font-smoothing: antialiased; 14 | } 15 | img, picture, video, canvas, svg { 16 | display: block; 17 | max-width: 100%; 18 | } 19 | input, button, textarea, select { 20 | font: inherit; 21 | } 22 | p, h1, h2, h3, h4, h5, h6 { 23 | overflow-wrap: break-word; 24 | } 25 | #root, #__next { 26 | isolation: isolate; 27 | } 28 | button, input, select, textarea { 29 | outline: none; 30 | -webkit-text-size-adjust: none; 31 | } 32 | -------------------------------------------------------------------------------- /src/frontend/www/style/search.css: -------------------------------------------------------------------------------- 1 | form { 2 | width: 100%; 3 | background: white; 4 | padding: 3rem; 5 | } 6 | 7 | .search_input { 8 | position: relative; 9 | } 10 | 11 | .search_input_left { 12 | position: absolute; 13 | top: 14px; 14 | left: 18px; 15 | content: ""; 16 | width: 24px; 17 | height: 24px; 18 | background: url(https://weread-1258476243.file.myqcloud.com/web/wrwebnjlogic/image/search_magnifier_focus_white.197e0b86.png) no-repeat; 19 | background-size: 100%; 20 | } 21 | 22 | .search_input_text { 23 | background-color: #fff; 24 | color: #212832; 25 | width: 100%; 26 | padding: 0 52px; 27 | box-sizing: border-box; 28 | height: 52px; 29 | border-radius: 26px; 30 | font-size: 16px; 31 | background-color: rgba(238, 240, 244, .12); 32 | border: 1px solid #d1d1d1; 33 | 34 | &::placeholder { 35 | color: #b8b8b8; 36 | } 37 | } 38 | .search_input_right { 39 | background: url(https://weread-1258476243.file.myqcloud.com/web/wrwebnjlogic/image/search_return_white.0c921c5a.png) no-repeat; 40 | background-size: 100%; 41 | position: absolute; 42 | top: 10px; 43 | right: 10px; 44 | content: ""; 45 | width: 32px; 46 | height: 32px; 47 | cursor: pointer; 48 | outline: none; 49 | border: none; 50 | } 51 | .search_input_loading { 52 | right: 26px; 53 | top: 23px; 54 | } 55 | -------------------------------------------------------------------------------- /src/frontend/www/user.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | User 6 | 26 | 27 | 28 |
    29 | 头像 30 |

    31 |

    32 |

    33 |
    34 | 35 | 46 | 47 | 84 | 85 | 86 | -------------------------------------------------------------------------------- /src/kv/credential.ts: -------------------------------------------------------------------------------- 1 | import kv from "./db.ts" 2 | 3 | /** 4 | * 用户凭证 5 | */ 6 | export interface Credential { 7 | token: string; 8 | vid: number; 9 | name: string; 10 | skey: string; 11 | rt: string; 12 | updatedAt: number; 13 | } 14 | 15 | 16 | /** 17 | * 根据 token 获取用户凭证 18 | * @param token 用户token 19 | */ 20 | export async function getByToken(token: string): Promise { 21 | const credentialEntry = await kv.get(["credentials", token]); 22 | if (!credentialEntry.value) { 23 | return { 24 | token: '', 25 | vid: -1, 26 | name: 'unknown', 27 | skey: '', 28 | rt: '', 29 | updatedAt: 0 30 | } 31 | } 32 | return credentialEntry.value as Credential; 33 | } 34 | 35 | /** 36 | * 根据 vid 获取用户 token 37 | * @description 权限比较大,需要小心限制使用 38 | * @param vid 39 | */ 40 | export async function getTokenByVid(vid: number): Promise { 41 | const tokenEntry = await kv.get(["vid", vid]) 42 | if (tokenEntry.value) { 43 | return tokenEntry.value as string 44 | } else { 45 | return null 46 | } 47 | } 48 | 49 | /** 50 | * 将 credential 转化为 cookie 51 | * @param credential 52 | */ 53 | export function getCookieByCredential(credential: Credential) { 54 | const {vid, skey, rt} = credential; 55 | return `wr_vid=${vid};wr_skey=${skey};wr_rt=${rt};`; 56 | } 57 | 58 | /** 59 | * 更新用户凭证 60 | * @param credential 61 | */ 62 | export async function update(credential: Credential) { 63 | await kv.atomic() 64 | .set(["credentials", credential.token], credential) 65 | .set(["vid", credential.vid], credential.token) 66 | .commit() 67 | } 68 | -------------------------------------------------------------------------------- /src/kv/db.ts: -------------------------------------------------------------------------------- 1 | import {dotenv} from "../deps.ts" 2 | import {runInDenoDeploy} from "../utils/index.ts"; 3 | 4 | let kv: Deno.Kv 5 | 6 | if (runInDenoDeploy()) { 7 | kv = await Deno.openKv() 8 | } else { 9 | const env = await dotenv.load() 10 | Deno.env.set('DENO_KV_ACCESS_TOKEN', env["DENO_KV_ACCESS_TOKEN"]) 11 | kv = await Deno.openKv( 12 | `https://api.deno.com/databases/${env["DENO_KV_UUID"]}/connect`, 13 | ); 14 | } 15 | 16 | export default kv 17 | -------------------------------------------------------------------------------- /src/kv/download.ts: -------------------------------------------------------------------------------- 1 | import { MAX_DOWNLOAD_COUNT_PER_MONTH } from "../config.ts"; 2 | import kv from "./db.ts" 3 | import {now} from "../utils/index.ts"; 4 | import {insertDownloadRecords} from "../database/download.ts"; 5 | import type { Credential } from "./credential.ts"; 6 | 7 | 8 | interface DownloadSecret { 9 | bookId: string; 10 | chapterUids: number[]; 11 | } 12 | 13 | /** 14 | * 检查下载量是否超过限额 15 | * @param credential 16 | */ 17 | export async function checkDownloadCount(credential: Credential) { 18 | const entry = await kv.get(["download", credential.vid]); 19 | return (!entry.value || entry.value < MAX_DOWNLOAD_COUNT_PER_MONTH); 20 | } 21 | 22 | /** 23 | * 增加下载量 24 | * @param credential 25 | * @param bookId 26 | */ 27 | export async function incrementDownloadCount(credential: Credential, bookId: string) { 28 | await kv.atomic().sum(["download", credential.vid], 1n).commit(); 29 | 30 | // 记录下载的书 31 | await insertDownloadRecords([{ 32 | vid: credential.vid.toString(), 33 | book_id: bookId, 34 | timestamp: now(), 35 | }]) 36 | } 37 | 38 | /** 39 | * 生成临时下载凭证 40 | * @param credential 41 | * @param bookId 42 | * @param chapterUids 43 | */ 44 | export async function newDownloadSecret( 45 | credential: Credential, 46 | bookId: string, 47 | chapterUids: number[], 48 | ) { 49 | const secret = crypto.randomUUID(); 50 | const payload: DownloadSecret = { 51 | bookId: bookId, 52 | chapterUids: chapterUids, 53 | } 54 | await kv.set(["download", credential.token, secret], payload, { 55 | expireIn: 1000 * 60 * 5, // 5分钟有效 56 | }); 57 | return secret; 58 | } 59 | 60 | 61 | 62 | /** 63 | * 使用下载凭证,有效期内(5分钟)可重复使用 64 | * @param credential 65 | * @param secret 66 | */ 67 | export async function useSecret( 68 | credential: Credential, 69 | secret: string, 70 | ): Promise<[boolean, string, number[]]> { 71 | const entry = await kv.get(["download", credential.token, secret]); 72 | if (entry.value) { 73 | return [true, entry.value.bookId, entry.value.chapterUids]; 74 | } 75 | return [false, "", []]; 76 | } 77 | -------------------------------------------------------------------------------- /src/kv/task.ts: -------------------------------------------------------------------------------- 1 | import kv from "./db.ts" 2 | import type {Credential} from "./credential.ts"; 3 | 4 | // 自动阅读的书籍信息 5 | export interface BookInfo { 6 | bookId: string 7 | title: string 8 | author: string 9 | } 10 | 11 | // 自动阅读任务 12 | export interface ReadingTask { 13 | credential: Credential 14 | book: BookInfo 15 | params: Record 16 | seconds: number 17 | createdAt: number 18 | updatedAt: number 19 | isActive: boolean 20 | } 21 | 22 | 23 | export async function setReaderToken(readerToken: string) { 24 | await kv.set(["reader.token"], readerToken) 25 | } 26 | 27 | export async function getReaderToken(): Promise { 28 | return (await kv.get(["reader.token"])).value 29 | } 30 | 31 | /** 32 | * 添加阅读任务 33 | * @param credential 34 | * @param bookInfo 35 | * @param pc 36 | * @param ps 37 | * @param createdAt 38 | */ 39 | export async function addReadingTask(credential: Credential, bookInfo: BookInfo, pc: number, ps: number, createdAt = Date.now()) { 40 | // 添加新的任务 41 | const task: ReadingTask = { 42 | credential: credential, 43 | book: bookInfo, 44 | params: { 45 | pc, 46 | ps, 47 | }, 48 | seconds: 0, 49 | createdAt: createdAt, 50 | updatedAt: Date.now(), 51 | isActive: true, 52 | } 53 | await kv.set(["task", "read", credential.vid], task) 54 | } 55 | 56 | /** 57 | * 更新阅读任务 58 | * @param credential 59 | * @param seconds 60 | */ 61 | export async function updateReadingTask(credential: Credential, seconds = 0) { 62 | const entry = await kv.get(["task", "read", credential.vid]) 63 | if (!entry.value) { 64 | // 任务不存在 65 | console.warn(`任务不存在: (vid: ${credential.vid}, name: ${credential.name})`, entry) 66 | return 67 | } 68 | 69 | const task = entry.value as ReadingTask 70 | 71 | // 执行时间(中国时间) 72 | const date = new Intl.DateTimeFormat("zh-CN", { 73 | dateStyle: "short", 74 | timeStyle: "medium", 75 | timeZone: "Asia/Shanghai", 76 | }).format(Date.now()).split(' ')[0] 77 | const lastUpdateDate = new Intl.DateTimeFormat("zh-CN", { 78 | dateStyle: "short", 79 | timeStyle: "medium", 80 | timeZone: "Asia/Shanghai", 81 | }).format(task.updatedAt).split(' ')[0] 82 | 83 | if (date > lastUpdateDate) { 84 | // 当天第一次执行 85 | task.seconds = seconds 86 | } else if (date === lastUpdateDate) { 87 | task.seconds += seconds 88 | } else { 89 | console.warn(`task更新时间:${date}, 上次更新时间:${lastUpdateDate}`) 90 | console.warn(credential, seconds) 91 | } 92 | task.updatedAt = Date.now() 93 | await kv.set(["task", "read", credential.vid], task) 94 | } 95 | 96 | /** 97 | * 暂停阅读任务 98 | * @param task 99 | */ 100 | export async function pauseReadTask(task: ReadingTask) { 101 | task.isActive = false 102 | await kv.set(["task", "read", task.credential.vid], task) 103 | } 104 | 105 | /** 106 | * 更新阅读任务中的token 107 | * 当用户换设备登录,或者清除缓存重新登录时,token会发生变化,所以在登录成功时需要同步替换任务中的token 108 | * @param credential 109 | */ 110 | export async function updateTaskToken(credential: Credential) { 111 | const taskEntry = await kv.get(["task", "read", credential.vid]) 112 | if (taskEntry.value) { 113 | const task = taskEntry.value as ReadingTask 114 | task.credential = credential 115 | task.isActive = true 116 | await kv.set(taskEntry.key, task) 117 | } 118 | } 119 | 120 | /** 121 | * 检索用户的任务 122 | * @param credential 123 | */ 124 | export async function getReadingTask(credential: Credential): Promise { 125 | const entry = await kv.get(["task", "read", credential.vid]) 126 | if (entry.value) { 127 | return entry.value as ReadingTask 128 | } else { 129 | return null 130 | } 131 | } 132 | 133 | /** 134 | * 查询所有用户的阅读任务 135 | */ 136 | export async function getAllReadingTask(): Promise { 137 | const tasks: ReadingTask[] = [] 138 | for await (const task of kv.list({prefix: ["task", "read"]})) { 139 | tasks.push(task.value) 140 | } 141 | return tasks 142 | } 143 | 144 | /** 145 | * 删除阅读任务 146 | * @param credential 147 | */ 148 | export async function removeReadingTask(credential: Credential) { 149 | await kv.delete(["task", "read", credential.vid]) 150 | } 151 | -------------------------------------------------------------------------------- /src/router.ts: -------------------------------------------------------------------------------- 1 | import {loginSSE} from "./frontend/apis/loginSSE.ts"; 2 | import { 3 | bookChapters, 4 | bookDetail, 5 | bookDownload, 6 | bookList, 7 | bookSearch, 8 | getDownloadSecret, 9 | } from "./frontend/apis/shelf.ts"; 10 | import {reviewList} from "./frontend/apis/review.ts"; 11 | import {userInfo} from "./frontend/apis/user.ts"; 12 | import { 13 | friendRank, 14 | queryTask, 15 | startRead, 16 | stopRead, 17 | } from "./frontend/apis/task.ts"; 18 | import {runExchangeTask} from "./cron/exchange.ts"; 19 | import {runReadTask} from "./cron/read.ts"; 20 | import {getPdfUrl} from "./frontend/apis/misc.ts"; 21 | 22 | type APIHandler = (req: Request) => Response | Promise 23 | 24 | const config: Record = { 25 | '/api/user/login': loginSSE, 26 | '/api/user/info': userInfo, 27 | 28 | '/api/shelf/book/list': bookList, 29 | '/api/book/detail': bookDetail, 30 | '/api/book/chapters': bookChapters, 31 | '/api/book/download/secret': getDownloadSecret, 32 | '/api/book/download': bookDownload, 33 | '/api/book/search': bookSearch, 34 | '/api/book/getUrl': getPdfUrl, 35 | 36 | '/api/review/list': reviewList, 37 | 38 | '/api/friend/rank': friendRank, // 查询读书排行榜 39 | '/api/task/read/start': startRead, // 加入自动阅读 40 | '/api/task/read/stop': stopRead, // 取消自动阅读 41 | '/api/task/read/query': queryTask, // 查询阅读任务 42 | 43 | '/cron/exchange-awards': runExchangeTask, // 兑换体验卡 44 | '/cron/read/v2': runReadTask, // 自动阅读任务 45 | } 46 | 47 | /** 48 | * 处理前端api请求 49 | * @param api 50 | * @param req 51 | */ 52 | export function routeApi(api: string, req: Request) { 53 | if (api in config) { 54 | return config[api](req) 55 | } else { 56 | return new Response(null, { 57 | status: 502, 58 | }) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { fs } from "./deps.ts"; 2 | import { routeApi } from "./router.ts"; 3 | 4 | const pattern = new URLPattern({ pathname: "/(api|cron)/:name+" }); 5 | 6 | Deno.serve((req: Request) => { 7 | const matchResult = pattern.exec(req.url); 8 | if (matchResult) { 9 | // api请求 10 | return routeApi(matchResult.pathname.input, req); 11 | } else { 12 | // 静态页面请求 13 | return fs.serveDir(req, { 14 | fsRoot: "src/frontend/www", 15 | quiet: true, 16 | }); 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /src/utils/decrypt.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * utils.js 中的 1111模块(解密及验证工具) 3 | */ 4 | 5 | import { base64Decode, md5 } from "./encode.ts"; 6 | 7 | function decrypt(data: string) { 8 | if (!data || "string" != typeof data || data.length <= 1) { 9 | return ""; 10 | } 11 | let result = data.slice(1); 12 | result = function (result) { 13 | const _0x402072 = function () { 14 | const len = result.length; 15 | if (len < 4) { 16 | return []; 17 | } 18 | if (len < 11) { 19 | return [0, 2]; 20 | } 21 | 22 | const _0x20b71e = Math.min(4, Math.ceil(len / 10)); 23 | let _0x2afb18 = ""; 24 | for (let i = len - 1; i > len - 1 - _0x20b71e; i--) { 25 | const _0x186eec = result.charCodeAt(i); 26 | _0x2afb18 += parseInt(_0x186eec.toString(2), 4); 27 | } 28 | 29 | const _0x27af8b = len - _0x20b71e - 2, 30 | _0x586d78 = _0x27af8b.toString().length, 31 | _0x1d71d6 = []; 32 | for ( 33 | let i = 0; 34 | _0x1d71d6.length < 10 && i + _0x586d78 < _0x2afb18.length; 35 | i += _0x586d78 36 | ) { 37 | let _0x352ab7 = parseInt(_0x2afb18.slice(i, i + _0x586d78)); 38 | _0x1d71d6.push(_0x352ab7 % _0x27af8b); 39 | _0x352ab7 = parseInt(_0x2afb18.slice(i + 1, i + 1 + _0x586d78)); 40 | _0x1d71d6.push(_0x352ab7 % _0x27af8b); 41 | } 42 | return _0x1d71d6; 43 | }(); 44 | return function (_0x4e56fa, _0x11d5c6) { 45 | const _0x51ba85 = _0x4e56fa.split(""); 46 | for (let i = _0x11d5c6.length - 1; i >= 0; i -= 2) { 47 | for (let j = 1; j >= 0; j--) { 48 | const _0x262bf2 = _0x51ba85[_0x11d5c6[i] + j]; 49 | _0x51ba85[_0x11d5c6[i] + j] = _0x51ba85[_0x11d5c6[i - 0x1] + j]; 50 | _0x51ba85[_0x11d5c6[i - 1] + j] = _0x262bf2; 51 | } 52 | } 53 | return _0x51ba85.join(""); 54 | }(result, _0x402072); 55 | }(result); 56 | 57 | result = base64Decode(result); 58 | return result; 59 | } 60 | 61 | export function chk(data: string) { 62 | if (!data || data.length <= 32) { 63 | return data; 64 | } 65 | 66 | const header = data.slice(0, 32); 67 | const body = data.slice(32); 68 | return header === md5(body).toUpperCase() ? body : ""; 69 | } 70 | 71 | export function dT(data: string) { 72 | return data && 0 !== data.length ? decrypt(data) : ""; 73 | } 74 | 75 | export function dH(data: string) { 76 | return data && 0 !== data.length ? decrypt(data) : ""; 77 | } 78 | 79 | export function dS(data: string) { 80 | return data && 0 !== data.length ? decrypt(data) : ""; 81 | } 82 | 83 | export function cs(data: string, radix = 21) { 84 | let result = ""; 85 | for (let i = 0, strlen = data.length; i < strlen; i += 2) { 86 | result += String.fromCharCode(parseInt(data.slice(i, i + 2), radix)); 87 | } 88 | return result; 89 | } 90 | -------------------------------------------------------------------------------- /src/utils/encode.ts: -------------------------------------------------------------------------------- 1 | import {base64, crypto} from "../deps.ts" 2 | 3 | /** 4 | * md5 哈希 5 | * @param raw 6 | */ 7 | export function md5(raw: string): string { 8 | const buf = new TextEncoder().encode(raw).buffer; 9 | return crypto.toHashString(crypto.crypto.subtle.digestSync("MD5", buf)); 10 | } 11 | 12 | /** 13 | * sha-256 哈希 14 | * @param raw 15 | */ 16 | export function sha256(raw: string): string { 17 | const buf = new TextEncoder().encode(raw).buffer; 18 | return crypto.toHashString(crypto.crypto.subtle.digestSync("SHA-256", buf)); 19 | } 20 | 21 | /** 22 | * base64 解码 23 | * @param input 24 | */ 25 | export function base64Decode(input: string) { 26 | return new TextDecoder().decode(base64.decode(input)); 27 | } 28 | 29 | /** 30 | * base64 编码 31 | * @param input 32 | */ 33 | export function base64Encode(input: string) { 34 | return base64.encode(input); 35 | } 36 | 37 | // todo: 用户登录后的token采用 aes(vid + '一个固定的随机数') 38 | // 这样可以避免因用户删除本地缓存后重新登录,token变化的问题 39 | export async function aes() { 40 | const key = await crypto.crypto.subtle.generateKey( 41 | { name: "AES-CBC", length: 128 }, 42 | true, 43 | ["encrypt", "decrypt"], 44 | ) 45 | console.log(key) 46 | const jwk = await crypto.crypto.subtle.exportKey("jwk", key) 47 | 48 | const k = await crypto.crypto.subtle.importKey("jwk", jwk, "AES-CBC", true, ["encrypt", "decrypt"]) 49 | return k 50 | } 51 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import { ulid } from "../deps.ts"; 2 | import { md5 } from "./encode.ts"; 3 | 4 | 5 | export const getUlid = ulid.monotonicFactory(); 6 | 7 | 8 | /** 9 | * 根据 ua 生成 appid 10 | * @param ua 用户代理字符串 11 | */ 12 | export function getAppId(ua: string) { 13 | let rnd1 = ""; 14 | const uaParts = ua.split(" "); 15 | const uaPartCount = Math.min(uaParts.length, 12); 16 | 17 | for (let i = 0; i < uaPartCount; i++) { 18 | rnd1 += uaParts[i].length % 10; 19 | } 20 | 21 | let rnd2 = function (ua) { 22 | let num = 0; 23 | const len = ua.length; 24 | 25 | for (let i = 0; i < len; i++) { 26 | num = 131 * num + ua.charCodeAt(i) & 0x7fffffff; 27 | } 28 | return num.toString(); 29 | }(ua); 30 | if (rnd2.length > 16) { 31 | rnd2 = rnd2.slice(0, 16); 32 | } 33 | return "wb" + rnd1 + "h" + rnd2; 34 | } 35 | 36 | function _sign(data: string): string { 37 | let n1 = 0x15051505; 38 | let n2 = 0x15051505; 39 | const strlen = data.length; 40 | 41 | for (let i = strlen - 1; i > 0; i -= 2) { 42 | n1 = 0x7fffffff & (n1 ^ data.charCodeAt(i) << (strlen - i) % 30); 43 | n2 = 0x7fffffff & (n2 ^ data.charCodeAt(i - 1) << i % 30); 44 | } 45 | return (n1 + n2).toString(16).toLowerCase(); 46 | } 47 | 48 | function _stringify(data: Record, keys: string[] = []) { 49 | let result = ""; 50 | const all = 0 === keys.length; 51 | const objKeys = Object.keys(data).sort(); 52 | 53 | for (let i = 0; i < objKeys.length; i++) { 54 | const key = objKeys[i]; 55 | if (all || -1 !== keys.indexOf(key)) { 56 | const value = data[key]; 57 | result += key + "=" + encodeURIComponent(value); 58 | result += "&"; 59 | } 60 | } 61 | if (result.length > 0 && "&" === result.charAt(result.length - 1)) { 62 | result = result.slice(0, result.length - 1); 63 | } 64 | return result; 65 | } 66 | 67 | /** 68 | * 计算 payload 的签名 69 | * @param data 70 | */ 71 | export function sign(data: Record): string { 72 | return _sign(_stringify(data)); 73 | } 74 | 75 | /** 76 | * 计算参数的 hash 77 | * @param data 78 | */ 79 | export function calcHash(data: string | number): string { 80 | if (typeof data === "number") { 81 | data = data.toString(); 82 | } 83 | if (typeof data !== "string") { 84 | return data; 85 | } 86 | 87 | const dataMd5 = md5(data); 88 | let _0x38b4d1 = dataMd5.substr(0, 3); // 3 89 | const _0x4718f7 = function (data) { 90 | if (/^\d*$/.test(data)) { 91 | const dataLen = data.length; 92 | const _0xd2c2b1 = []; 93 | for (let i = 0; i < dataLen; i += 9) { 94 | const _0x56eaa4 = data.slice(i, Math.min(i + 9, dataLen)); 95 | _0xd2c2b1.push(parseInt(_0x56eaa4).toString(16)); 96 | } 97 | return ["3", _0xd2c2b1]; 98 | } 99 | 100 | let _0x397242 = ""; 101 | for (let i = 0; i < data.length; i++) { 102 | _0x397242 += data.charCodeAt(i).toString(16); 103 | } 104 | return ["4", [_0x397242]]; 105 | }(data); 106 | 107 | _0x38b4d1 += _0x4718f7[0]; // 4 108 | _0x38b4d1 += 2 + dataMd5.substr(dataMd5.length - 2, 2); // 7 109 | 110 | const _0x1e41f3 = _0x4718f7[1]; 111 | for (let i = 0; i < _0x1e41f3.length; i++) { 112 | let _0x5c593c = _0x1e41f3[i].length.toString(16); 113 | 1 === _0x5c593c.length && (_0x5c593c = "0" + _0x5c593c); 114 | _0x38b4d1 += _0x5c593c; 115 | _0x38b4d1 += _0x1e41f3[i]; 116 | i < _0x1e41f3.length - 1 && (_0x38b4d1 += "g"); 117 | } 118 | 119 | if (_0x38b4d1.length < 20) { 120 | _0x38b4d1 += dataMd5.substr(0, 20 - _0x38b4d1.length); 121 | } 122 | 123 | return _0x38b4d1 + md5(_0x38b4d1).substr(0, 3); 124 | } 125 | 126 | /** 127 | * 当前时间,单位是秒 128 | */ 129 | export function currentTime() { 130 | return Math.floor(new Date().getTime() / 1000); 131 | } 132 | 133 | /** 134 | * 当前时间戳,单位是毫秒 135 | */ 136 | export function timestamp() { 137 | return new Date().getTime() 138 | } 139 | 140 | export function generateQRCode(data: string) { 141 | const query = new URLSearchParams({ 142 | cht: "qr", // Chart type 143 | chs: "300x300", // QR code dimensions 144 | chl: data, // Data embedded in QR code 145 | }); 146 | return "https://chart.googleapis.com/chart?" + query.toString(); 147 | } 148 | 149 | /** 150 | * 是否在deploy中运行代码 151 | */ 152 | export function runInDenoDeploy() { 153 | const deploymentId = Deno.env.get("DENO_DEPLOYMENT_ID") 154 | return !!deploymentId 155 | } 156 | 157 | /** 158 | * 睡眠 159 | * @param duration 毫秒 160 | */ 161 | export function sleep(duration: number) { 162 | return new Promise((resolve) => { 163 | setTimeout(resolve, duration); 164 | }); 165 | } 166 | 167 | 168 | export function now(): string { 169 | return new Intl.DateTimeFormat("zh-CN", { 170 | dateStyle: "short", 171 | timeStyle: "medium", 172 | timeZone: "Asia/Shanghai", 173 | }).format(new Date()); 174 | } 175 | 176 | function stringify(data: unknown) { 177 | return JSON.stringify(data) 178 | } 179 | 180 | export function jsonResponse(data: unknown) { 181 | return new Response( 182 | stringify(data), 183 | { 184 | headers: { 185 | "Content-Type": "application/json", 186 | }, 187 | }, 188 | ); 189 | } 190 | 191 | /** 192 | * 生成一个随机整数 193 | * @param min 最小值(包含) 194 | * @param max 最大值(包含) 195 | */ 196 | export function randomInteger(min: number, max: number) { 197 | // here rand is from min to (max+1) 198 | const rand = min + Math.random() * (max + 1 - min); 199 | return Math.floor(rand); 200 | } 201 | 202 | /** 203 | * 格式化秒 204 | * @param seconds 205 | */ 206 | export function formatSeconds(seconds: number) { 207 | if (typeof seconds !== 'number') { 208 | return seconds 209 | } 210 | if (seconds < 60) { 211 | return `${seconds}s` 212 | } 213 | const minutes = Math.floor(seconds / 60) 214 | const second = seconds % 60 215 | if (minutes < 60) { 216 | return `${minutes}m${second}s` 217 | } 218 | const hours = Math.floor(minutes / 60) 219 | const minute = minutes % 60 220 | return `${hours}h${minute}m${second}s` 221 | } 222 | -------------------------------------------------------------------------------- /src/utils/process.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 8.js中的 UPDATE_READER_CONTENT_HTML 和 UPDATE_READER_CONTENT_STYLES 这两个 mutation 3 | */ 4 | 5 | /** 6 | * 处理样式 7 | * @param styles 8 | * @param bookId 9 | */ 10 | export function processStyles(styles: string, bookId: string) { 11 | return function (styles, bookId) { 12 | if (!styles || styles.length <= 0) { 13 | return ""; 14 | } 15 | // 去掉 /* */ 注释 16 | styles = styles.trim().replace(/\/\*.*?\*\//gi, ""); 17 | 18 | const matchArray = styles.match(/[^{}]*?{[\s\S]+?}/gi); 19 | if (!matchArray || 0 === matchArray.length) { 20 | return ""; 21 | } 22 | 23 | return matchArray.map((_0x4fc4e3) => { 24 | return ".readerChapterContent " + 25 | (_0x4fc4e3 = _0x4fc4e3.trim()).split("\n").map(function (_0xde9e6d) { 26 | return -1 === (_0xde9e6d = _0xde9e6d.trim()).indexOf("{") && 27 | -1 === _0xde9e6d.indexOf("}") && -1 === _0xde9e6d.indexOf(";") 28 | ? _0xde9e6d + ";" 29 | : _0xde9e6d; 30 | }).join(""); 31 | }).join("").trim().replace( 32 | /\.\.\/images\/(.*?\.(png|jpg|jpeg|gif))/gi, 33 | "https://res.weread.qq.com/wrepub/web/" + bookId + "/$1", 34 | ); 35 | }(styles || "", bookId); 36 | } 37 | 38 | /** 39 | * 处理html 40 | * @param htmls 41 | * @param bookId 42 | */ 43 | export function processHtmls(htmls: string[], bookId: string) { 44 | return htmls.map((html) => { 45 | return function (html, bookId) { 46 | if (!html || html.length <= 0) { 47 | return ""; 48 | } 49 | const re1 = 50 | /]+?data-wr-co="([^"]+?)"[^>]+?alt="([^"]+?)"[^>]+?qqreader-footnote[^>]+?>/gi; 51 | html = html.replace( 52 | re1, 53 | '', 54 | ); 55 | 56 | const re2 = 57 | /]+?data-wr-co="([^"]+?)"[^>]+?qqreader-footnote[^>]+?alt="([^"]+?)"[^>]*?>/gi; 58 | html = html.replace( 59 | re2, 60 | '', 61 | ); 62 | html = html.replace( 63 | /\.\.\/video\/(.*?\.(mp4|wmv|3gp|rm|rmvb|mov|m4v|avi))/gi, 64 | "https://res.weread.qq.com/wrepub/web/" + bookId + "/$1", 65 | ); 66 | html = html.replace( 67 | /\.\.\/images\/(.*?\.(png|jpg|jpeg|gif))/gi, 68 | "https://res.weread.qq.com/wrepub/web/" + bookId + "/$1", 69 | ); 70 | html = html.trim(); 71 | 72 | return html; 73 | }(html || "", bookId); 74 | }); 75 | } 76 | -------------------------------------------------------------------------------- /src/utils/request.ts: -------------------------------------------------------------------------------- 1 | import { UserAgentForWeb } from "../config.ts"; 2 | 3 | function stringifyQuery( 4 | query: Record = {}, 5 | ): Record { 6 | const data: Record = {}; 7 | Object.keys(query).reduce((obj, key) => { 8 | obj[key] = query[key].toString(); 9 | return obj; 10 | }, data); 11 | return data; 12 | } 13 | 14 | export function get( 15 | url: string, 16 | query: Record = {}, 17 | header: Record = {}, 18 | ) { 19 | if (Object.keys(query).length) { 20 | url += "?" + new URLSearchParams(stringifyQuery(query)).toString(); 21 | } 22 | const headers: Record = { 23 | "User-Agent": UserAgentForWeb, 24 | ...header, 25 | }; 26 | return fetch(url, { 27 | method: "GET", 28 | cache: "default", 29 | headers, 30 | }); 31 | } 32 | 33 | function post( 34 | url: string, 35 | data: Record = {}, 36 | format = "json", 37 | header: Record = {}, 38 | ) { 39 | let body; 40 | const headers: Record | undefined = { 41 | "User-Agent": UserAgentForWeb, 42 | ...header, 43 | }; 44 | 45 | if (format === "query" && Object.keys(data).length) { 46 | url += "?" + new URLSearchParams(stringifyQuery(data)).toString(); 47 | body = undefined; 48 | } else if (format === "json") { 49 | body = JSON.stringify(data); 50 | headers["Content-Type"] = "application/json"; 51 | } else if (format === "form-data") { 52 | const formData = new FormData(); 53 | Object.keys(data).forEach((key) => { 54 | formData.append(key, data[key]); 55 | }); 56 | body = formData; 57 | } 58 | return fetch(url, { 59 | method: "POST", 60 | cache: "no-store", 61 | body, 62 | headers, 63 | }); 64 | } 65 | 66 | export function postJSON( 67 | url: string, 68 | data: Record = {}, 69 | headers: Record = {}, 70 | ) { 71 | return post(url, data, "json", headers); 72 | } 73 | 74 | export function postQuery( 75 | url: string, 76 | data: Record = {}, 77 | headers: Record = {}, 78 | ) { 79 | return post(url, data, "query", headers); 80 | } 81 | 82 | export function postFormData( 83 | url: string, 84 | data: Record = {}, 85 | headers: Record = {}, 86 | ) { 87 | return post(url, data, "form-data", headers); 88 | } 89 | -------------------------------------------------------------------------------- /src/utils/style.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 实现 utils.js 中的 1039 模块: style-parser 3 | */ 4 | 5 | // 由于 deno compile 存在 bug,所以 csstree 这个包暂时不放在 deps.ts 中 6 | // import {csstree} from "../deps.ts" 7 | import * as csstree from "npm:css-tree@2.3.1"; 8 | 9 | interface ParseOptions { 10 | removeFontSizes: boolean; 11 | enableTranslate: boolean; 12 | } 13 | 14 | function removeAllFontSizes(style: string) { 15 | const ast = csstree.parse(style); 16 | csstree.walk(ast, { 17 | "visit": "Declaration", 18 | "enter": function (node: any, item: any, list: any) { 19 | if ("font-size" === node.property) { 20 | list.remove(item); 21 | } 22 | }, 23 | }); 24 | return csstree.generate(ast); 25 | } 26 | 27 | function removeTopClassSpanStyle(style: string) { 28 | const ast = csstree.parse(style); 29 | csstree.walk(ast, { 30 | "visit": "Rule", 31 | "enter": function (node: any, item: any, list: any) { 32 | const selectorList = node.prelude; 33 | 34 | if ( 35 | csstree.find( 36 | selectorList, 37 | (node: any) => node.type === "TypeSelector" && node.name === "span", 38 | ) && 39 | !csstree.find( 40 | selectorList, 41 | (node: any) => 42 | node.type === "ClassSelector" || node.type === "IdSelector", 43 | ) 44 | ) { 45 | list.remove(item); 46 | } 47 | }, 48 | }); 49 | return csstree.generate(ast); 50 | } 51 | 52 | function parse(style: string, options: Partial = {}) { 53 | // 添加字体 54 | style = style.replace( 55 | /font-family:([^;]*?);/g, 56 | 'font-family:$1,"PingFang SC", -apple-system, "SF UI Text", "Lucida Grande", STheiti, "Microsoft YaHei", sans-serif;', 57 | ); 58 | 59 | if (options.removeFontSizes) { 60 | style = removeAllFontSizes(style); 61 | } 62 | 63 | style = removeTopClassSpanStyle(style); 64 | 65 | // remove relative position 66 | const ast1 = csstree.parse(style); 67 | csstree.walk(ast1, { 68 | "visit": "Declaration", 69 | "enter": function (node: any, item: any, list: any) { 70 | // todo: 感觉这里有问题 71 | // if (node.property === 'position' && node.value.children.size === 1 && node.value.children.first.name === 'relative') { 72 | // list.remove(item) 73 | // } 74 | if ( 75 | "position" === node.property && "Identifier" === node.value.type && 76 | "relative" === node.value.name 77 | ) { 78 | list.remove(item); 79 | } 80 | }, 81 | }); 82 | style = csstree.generate(ast1); 83 | 84 | // remove code style 85 | const ast2 = csstree.parse(style); 86 | csstree.walk(ast2, { 87 | "visit": "Rule", 88 | "enter": function (node: any, item: any, list: any) { 89 | // todo: 这里也有问题,应该用find才能搜索到 90 | // if (csstree.find(node.prelude, (node: any) => node.type === 'TypeSelector' && node.name === 'code')) { 91 | // list.remove(item) 92 | // } 93 | if ( 94 | "TypeSelector" === node.prelude.type && "code" === node.prelude.name 95 | ) { 96 | list.remove(item); 97 | } 98 | }, 99 | }); 100 | style = csstree.generate(ast2); 101 | 102 | if (options.enableTranslate) { 103 | style = style.replace(/\.wr-translation\s*?\{(?:\n|.|\r)*?}/g, ""); 104 | } 105 | return style; 106 | } 107 | 108 | export default { 109 | "parse": parse, 110 | "removeAllFontSizes": removeAllFontSizes, 111 | }; 112 | --------------------------------------------------------------------------------