├── images ├── fuzzy.jpg ├── icon.jpeg ├── main.jpg ├── nick.png ├── start.jpg ├── keybind.jpg ├── online.jpg ├── search.jpg ├── new_book.jpg ├── show_text.jpg ├── start_icon.png ├── fuzzy_online.png ├── online_list.png ├── select_book.jpg └── certificate_expire.png ├── .gitignore ├── src ├── const.ts ├── parse │ ├── model.ts │ ├── interface.ts │ ├── txt.ts │ ├── caimo.ts │ └── biqu.ts ├── crawler │ ├── interface.ts │ ├── caimo.ts │ └── biqu.ts ├── test │ ├── suite │ │ ├── extension.test.ts │ │ ├── index.ts │ │ └── chinese_to_number.test.ts │ └── runTest.ts ├── util.ts ├── chinese_to_number.ts ├── extension.ts ├── store.ts ├── read.ts └── menu.ts ├── .vscodeignore ├── .vscode ├── extensions.json ├── tasks.json ├── settings.json └── launch.json ├── .eslintrc.json ├── tsconfig.json ├── CHANGELOG.md ├── README.md └── package.json /images/fuzzy.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/fuzzy.jpg -------------------------------------------------------------------------------- /images/icon.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/icon.jpeg -------------------------------------------------------------------------------- /images/main.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/main.jpg -------------------------------------------------------------------------------- /images/nick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/nick.png -------------------------------------------------------------------------------- /images/start.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/start.jpg -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | node_modules 4 | .vscode-test/ 5 | *.vsix 6 | *.pyproj 7 | .vs/ -------------------------------------------------------------------------------- /images/keybind.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/keybind.jpg -------------------------------------------------------------------------------- /images/online.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/online.jpg -------------------------------------------------------------------------------- /images/search.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/search.jpg -------------------------------------------------------------------------------- /images/new_book.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/new_book.jpg -------------------------------------------------------------------------------- /images/show_text.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/show_text.jpg -------------------------------------------------------------------------------- /images/start_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/start_icon.png -------------------------------------------------------------------------------- /images/fuzzy_online.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/fuzzy_online.png -------------------------------------------------------------------------------- /images/online_list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/online_list.png -------------------------------------------------------------------------------- /images/select_book.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/select_book.jpg -------------------------------------------------------------------------------- /images/certificate_expire.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/igzhang/shadowReader/HEAD/images/certificate_expire.png -------------------------------------------------------------------------------- /src/const.ts: -------------------------------------------------------------------------------- 1 | export const CrawelerDomains = new Map([ 2 | ["biquURL", "https://www.biqugee6.com"], 3 | ["caimoURL", "https://www.caimoge.net"], 4 | ]); 5 | -------------------------------------------------------------------------------- /.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | out/test/** 4 | src/** 5 | .gitignore 6 | .yarnrc 7 | vsc-extension-quickstart.md 8 | **/tsconfig.json 9 | **/.eslintrc.json 10 | **/*.map 11 | **/*.ts 12 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See http://go.microsoft.com/fwlink/?LinkId=827846 3 | // for the documentation about the extensions.json format 4 | "recommendations": [ 5 | "dbaeumer.vscode-eslint" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/parse/model.ts: -------------------------------------------------------------------------------- 1 | export const enum BookKind { 2 | local, 3 | online, 4 | } 5 | 6 | export interface BookStore { 7 | kind: BookKind, 8 | readedCount: number, 9 | sectionPath?: string, 10 | } 11 | -------------------------------------------------------------------------------- /src/crawler/interface.ts: -------------------------------------------------------------------------------- 1 | export interface Craweler { 2 | // search book according to keyword 3 | // return: key is name, value is index html 4 | searchBook(keyWord: string): Promise>; 5 | 6 | // find chapter detail url according to index html 7 | findChapterURL(url: string): Promise>; 8 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | // See https://go.microsoft.com/fwlink/?LinkId=733558 2 | // for the documentation about the tasks.json format 3 | { 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "type": "npm", 8 | "script": "watch", 9 | "problemMatcher": "$tsc-watch", 10 | "isBackground": true, 11 | "presentation": { 12 | "reveal": "never" 13 | }, 14 | "group": { 15 | "kind": "build", 16 | "isDefault": true 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module" 7 | }, 8 | "plugins": [ 9 | "@typescript-eslint" 10 | ], 11 | "rules": { 12 | "@typescript-eslint/naming-convention": "warn", 13 | "@typescript-eslint/semi": "warn", 14 | "curly": "warn", 15 | "eqeqeq": "warn", 16 | "no-throw-literal": "warn", 17 | "semi": "off" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/test/suite/extension.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | 3 | // You can import and use all API from the 'vscode' module 4 | // as well as import your extension to test it 5 | import * as vscode from 'vscode'; 6 | // import * as myExtension from '../../extension'; 7 | 8 | suite('Extension Test Suite', () => { 9 | vscode.window.showInformationMessage('Start all tests.'); 10 | 11 | test('Sample test', () => { 12 | assert.strictEqual(-1, [1, 2, 3].indexOf(5)); 13 | assert.strictEqual(-1, [1, 2, 3].indexOf(0)); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "files.exclude": { 4 | "out": false // set this to true to hide the "out" folder with the compiled JS files 5 | }, 6 | "search.exclude": { 7 | "out": true // set this to false to include "out" folder in search results 8 | }, 9 | // Turn off tsc task auto detection since we have the necessary tasks as npm scripts 10 | "typescript.tsc.autoDetect": "off", 11 | "remote.WSL1.connectionMethod": "wslExeProxy" 12 | } -------------------------------------------------------------------------------- /src/parse/interface.ts: -------------------------------------------------------------------------------- 1 | import { BookStore } from "./model"; 2 | 3 | export interface Parser { 4 | 5 | // close resource if necessary; 6 | close(): void; 7 | 8 | // get next page content, if string.length === 0, means no more content 9 | getNextPage(pageSize: number): Promise; 10 | 11 | // get prev page content 12 | getPrevPage(pageSize: number): Promise; 13 | 14 | // get percent info 15 | getPercent(): string; 16 | 17 | // get persist read history, used to cache history 18 | getPersistHistory(): BookStore; 19 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es6", 5 | "outDir": "out", 6 | "lib": [ 7 | "es6" 8 | ], 9 | "sourceMap": true, 10 | "rootDir": "src", 11 | "strict": true, /* enable all strict type-checking options */ 12 | "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 13 | "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 14 | "noUnusedParameters": true, /* Report errors on unused parameters. */ 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | ".vscode-test" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /src/test/runTest.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | 3 | import { runTests } from 'vscode-test'; 4 | 5 | async function main() { 6 | try { 7 | // The folder containing the Extension Manifest package.json 8 | // Passed to `--extensionDevelopmentPath` 9 | const extensionDevelopmentPath = path.resolve(__dirname, '../../'); 10 | 11 | // The path to test runner 12 | // Passed to --extensionTestsPath 13 | const extensionTestsPath = path.resolve(__dirname, './suite/index'); 14 | 15 | // Download VS Code, unzip it and run the integration test 16 | await runTests({ extensionDevelopmentPath, extensionTestsPath }); 17 | } catch (err) { 18 | console.error('Failed to run tests'); 19 | process.exit(1); 20 | } 21 | } 22 | 23 | main(); 24 | -------------------------------------------------------------------------------- /src/test/suite/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as Mocha from 'mocha'; 3 | import * as glob from 'glob'; 4 | 5 | export function run(): Promise { 6 | // Create the mocha test 7 | const mocha = new Mocha({ 8 | ui: 'tdd', 9 | color: true 10 | }); 11 | 12 | const testsRoot = path.resolve(__dirname, '..'); 13 | 14 | return new Promise((c, e) => { 15 | glob('**/**.test.js', { cwd: testsRoot }, (err, files) => { 16 | if (err) { 17 | return e(err); 18 | } 19 | 20 | // Add files to the test suite 21 | files.forEach(f => mocha.addFile(path.resolve(testsRoot, f))); 22 | 23 | try { 24 | // Run the mocha test 25 | mocha.run(failures => { 26 | if (failures > 0) { 27 | e(new Error(`${failures} tests failed.`)); 28 | } else { 29 | c(); 30 | } 31 | }); 32 | } catch (err) { 33 | console.error(err); 34 | e(err); 35 | } 36 | }); 37 | }); 38 | } 39 | -------------------------------------------------------------------------------- /src/test/suite/chinese_to_number.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import { convertChineseToNumber } from '../../chinese_to_number'; 3 | 4 | suite('Extension Test Suite', () => { 5 | 6 | test('chinese to number test', () => { 7 | assert.strictEqual(1, convertChineseToNumber("一")); 8 | assert.strictEqual(11, convertChineseToNumber("十一")); 9 | assert.strictEqual(34, convertChineseToNumber("三十四")); 10 | assert.strictEqual(50, convertChineseToNumber("五十")); 11 | assert.strictEqual(101, convertChineseToNumber("一百零一")); 12 | assert.strictEqual(619, convertChineseToNumber("六百一十九")); 13 | assert.strictEqual(807, convertChineseToNumber("八百零七")); 14 | assert.strictEqual(2006, convertChineseToNumber("二千零六")); 15 | assert.strictEqual(12006, convertChineseToNumber("一万二千零六")); 16 | assert.strictEqual(12345, convertChineseToNumber("12345")); 17 | assert.strictEqual(134, convertChineseToNumber("一三四")); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that compiles the extension and then opens it inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Run Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ], 15 | "outFiles": [ 16 | "${workspaceFolder}/out/**/*.js" 17 | ], 18 | "preLaunchTask": "${defaultBuildTask}" 19 | }, 20 | { 21 | "name": "Extension Tests", 22 | "type": "extensionHost", 23 | "request": "launch", 24 | "args": [ 25 | "--extensionDevelopmentPath=${workspaceFolder}", 26 | "--extensionTestsPath=${workspaceFolder}/out/test/suite/index" 27 | ], 28 | "outFiles": [ 29 | "${workspaceFolder}/out/test/**/*.js" 30 | ], 31 | "preLaunchTask": "${defaultBuildTask}" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { StatusBarItem, window, workspace } from "vscode"; 2 | 3 | export const searchToEndCommandID = "shadowReader.searchToEnd"; 4 | let myStatusBarItem: StatusBarItem = window.createStatusBarItem(); 5 | myStatusBarItem.command = searchToEndCommandID; 6 | 7 | const defaultBossText = "Hello world"; 8 | let lastReadText = ''; 9 | let showingText = ''; 10 | let timeoutInternal: NodeJS.Timeout | null = null; 11 | 12 | // 实际设置状态栏 13 | function _setStatusBar(msg: string) { 14 | if (msg.length > 0) { 15 | myStatusBarItem.text = msg; 16 | showingText = msg; 17 | myStatusBarItem.show(); 18 | } else { 19 | myStatusBarItem.hide(); 20 | } 21 | } 22 | 23 | // 显示老板信息 24 | function showBossText() { 25 | _setStatusBar(defaultBossText); 26 | } 27 | 28 | // 切换回正常信息 29 | function showNormalText() { 30 | _setStatusBar(lastReadText); 31 | if (timeoutInternal) { 32 | clearTimeout(timeoutInternal); 33 | } 34 | let timeoutSecond = (workspace.getConfiguration().get("shadowReader.hiddenTime")) * 1000; 35 | timeoutInternal = setTimeout(showBossText, timeoutSecond); 36 | } 37 | 38 | export function toggleBossMsg() { 39 | // 已经显示老板信息 40 | if (showingText === defaultBossText) { 41 | showNormalText(); 42 | } else { 43 | showBossText(); 44 | } 45 | } 46 | 47 | export function setStatusBarMsg(msg: string) { 48 | lastReadText = msg; 49 | showNormalText(); 50 | } 51 | -------------------------------------------------------------------------------- /src/chinese_to_number.ts: -------------------------------------------------------------------------------- 1 | const numHanMap = new Map([ 2 | ["一", 1], 3 | ["二", 2], 4 | ["三", 3], 5 | ["四", 4], 6 | ["五", 5], 7 | ["六", 6], 8 | ["七", 7], 9 | ["八", 8], 10 | ["九", 9], 11 | ["零", 0], 12 | ]); 13 | 14 | const unitHanMap = new Map([ 15 | ["十", 10], 16 | ["百", 100], 17 | ["千", 1000], 18 | ["万", 10000], 19 | ]); 20 | 21 | // convert chinese to number, for example “二十” -> 20 22 | // current support “十百千万” 23 | export function convertChineseToNumber(han: string): number { 24 | let directNum = parseInt(han, 10); 25 | if (!isNaN(directNum)) { 26 | return directNum; 27 | } 28 | 29 | let ans = 0; 30 | let num = 0; 31 | let withoutUnit = true; 32 | let stack: Array = []; 33 | 34 | // startwith unit, for example "十一" -> "一十一" 35 | if(unitHanMap.has(han[0])) { 36 | han = "一" + han; 37 | } 38 | 39 | for (let index = 0; index < han.length; index++) { 40 | if (numHanMap.has(han[index])) { 41 | num = numHanMap.get(han[index]); 42 | stack.push(num); 43 | if (index === han.length - 1) { 44 | ans += num; 45 | } 46 | } else if (unitHanMap.has(han[index])) { 47 | withoutUnit = false; 48 | ans += (unitHanMap.get(han[index])) * num; 49 | } 50 | } 51 | 52 | if (withoutUnit) { 53 | stack.pop(); 54 | let unit = 10; 55 | while (stack.length > 0) { 56 | ans += stack.pop() * unit; 57 | unit *= 10; 58 | } 59 | } 60 | 61 | return ans; 62 | } -------------------------------------------------------------------------------- /src/extension.ts: -------------------------------------------------------------------------------- 1 | // The module 'vscode' contains the VS Code extensibility API 2 | // Import the module and reference it with the alias vscode in your code below 3 | import * as vscode from 'vscode'; 4 | import { setStatusBarMsg, searchToEndCommandID, toggleBossMsg } from "./util"; 5 | import { showMainMenu, showSearchKeywordBox } from "./menu"; 6 | import { readNextLine, readPrevLine, closeAll } from "./read"; 7 | 8 | 9 | 10 | // this method is called when your extension is activated 11 | // your extension is activated the very first time the command is executed 12 | export function activate(context: vscode.ExtensionContext) { 13 | 14 | let searchKeyWordToEnd = vscode.commands.registerCommand(searchToEndCommandID, () => { 15 | showSearchKeywordBox(context); 16 | }); 17 | context.subscriptions.push(searchKeyWordToEnd); 18 | 19 | let getNextPage = vscode.commands.registerCommand("shadowReader.getNextPage", () => { 20 | readNextLine(context).then(text => { 21 | setStatusBarMsg(text); 22 | }); 23 | }); 24 | context.subscriptions.push(getNextPage); 25 | 26 | let getPrevPage = vscode.commands.registerCommand("shadowReader.getPrevPage", () => { 27 | readPrevLine(context).then(text => { 28 | setStatusBarMsg(text); 29 | }); 30 | }); 31 | context.subscriptions.push(getPrevPage); 32 | 33 | let startMain = vscode.commands.registerCommand("shadowReader.start", async () => { 34 | await showMainMenu(context); 35 | }); 36 | context.subscriptions.push(startMain); 37 | 38 | let showBossInfo = vscode.commands.registerCommand("shadowReader.showBossInfo", () => { 39 | toggleBossMsg(); 40 | }); 41 | context.subscriptions.push(showBossInfo); 42 | 43 | } 44 | 45 | // this method is called when your extension is deactivated 46 | export function deactivate() { 47 | closeAll(); 48 | } 49 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 版本改动日志 3 | 4 | ### 0.8.2 5 | >> 发布时间2022-11-03 6 | - bug fix:笔趣阁更换域名 7 | 8 | ### 0.8.1 9 | >> 发布时间2022-07-14 10 | - impovement:去除换行符,改为和原文一样换行 11 | - bug fix:笔趣阁更换域名无法访问 12 | 13 | ### 0.7.5 14 | >> 发布时间2022-06-27 15 | - bug fix:笔趣阁翻页问题 16 | 17 | ### 0.7.4 18 | >> 发布时间2022-06-22 19 | - impovement:笔趣阁遭遇验证码后,多次尝试 20 | - impovement:解决certificate expire问题,方法见known issues 21 | 22 | ### 0.7.3 23 | >> 发布时间2022-06-21 24 | - bug fix:笔趣阁无法访问及乱码问题 25 | 26 | ### 0.7.2 27 | >> 发布时间2022-02-28 28 | - impovement:修改右上角为书籍的图标 29 | 30 | ### 0.7.1 31 | >> 发布时间2022-02-24 32 | - feature:在线书籍新增“采墨阁”支持 33 | - feature:右上角新增“开始工资”快捷方式 34 | 35 | ### 0.6.2 36 | >> 发布时间2021-12-20 37 | - bug fix:修改笔趣阁域名 38 | 39 | ### 0.5.3 40 | >> 发布时间2021-09-08 41 | - impovement:去掉`shadowReader.chapterRegExp`配置,将章节选择由数字改为列表选择 42 | - impovement:优化网络书籍报错提示 43 | 44 | ### 0.5.2 45 | >> 发布时间2021-08-27 46 | - bug fix:在线书籍章节选取错误,详见[issue](https://github.com/igzhang/shadowReader/issues/11) 47 | 48 | ### 0.5.1 49 | >> 发布时间2021-07-25 50 | - feature:自动老板键--长时间不操作,默认使用老板键(感谢MerlinBlade提供的思路,详见[issue](https://github.com/igzhang/shadowReader/issues/7)) 51 | - impovement:第二次点击老板键,会返回原文本 52 | 53 | ### 0.4.1 54 | >> 发布时间2021-04-01 55 | - impovement:改版“网络书籍”功能,修改为目标网站在线阅读,当前支持笔趣阁(感谢isSamle提供的思路) 56 | 57 | ### 0.3.1 58 | >> 发布时间2021-03-25 59 | - feature:增加“网络书籍”选项 60 | 61 | ### 0.2.1 62 | >> 发布时间2021-03-22 63 | - feature:增加“删除书籍”、“根据内容向后查找”功能 64 | - bug fix:新打开的文件,进度为0% 65 | 66 | ### 0.1.3 67 | >> 发布时间2021-03-19 68 | - impovement:修改默认编码为utf32le 69 | - bug fix:退出vscode时,当前阅读进度未保存 70 | 71 | ### 0.1.2 72 | >> 发布时间2021-03-18 73 | - bug fix: 新添加utf8文件失败的问题 74 | - impovement:修改默认快捷键为alt,以避免影响注释功能 75 | 76 | ### 0.1.1 77 | >> 发布时间2021-03-18 78 | - impovement:全新用户UI 79 | - feature:自动转码(支持格式参考[chardet](https://www.npmjs.com/package/chardet)) 80 | 81 | ### 0.0.1 82 | >> 发布时间2021-03-16 83 | - feature:支持本地utf8文件 84 | - feature:自动保存当前书签 85 | -------------------------------------------------------------------------------- /src/crawler/caimo.ts: -------------------------------------------------------------------------------- 1 | import cheerioModule = require("cheerio"); 2 | import axios from "axios"; 3 | import iconv = require('iconv-lite'); 4 | import { window } from "vscode"; 5 | import { Craweler } from "./interface"; 6 | 7 | const querystring = require('querystring'); 8 | 9 | 10 | export class CaimoCrawler implements Craweler { 11 | 12 | private readonly baseURL = "https://www.caimoge.net"; 13 | private readonly defaultEncode = "utf-8"; 14 | 15 | async searchBook(keyWord: string): Promise> { 16 | let data: string; 17 | let self = this; 18 | try { 19 | const response = await axios.post(self.baseURL + "/search/", querystring.stringify({ searchkey: keyWord })); 20 | data = response.data; 21 | } catch (error: any) { 22 | window.showErrorMessage(error.message); 23 | throw error; 24 | } 25 | 26 | const $ = cheerioModule.load(data); 27 | let choices = new Map(); 28 | $("#sitembox h3>a").each(function (_i, ele) { 29 | choices.set($(ele).text(), self.baseURL + $(ele).prop("href")); 30 | }); 31 | return choices; 32 | 33 | } 34 | 35 | async findChapterURL(url: string): Promise> { 36 | let data: string; 37 | let self = this; 38 | try { 39 | const response = await axios.get(url, {responseType: "arraybuffer"}); 40 | data = iconv.decode(response.data, this.defaultEncode); 41 | } catch (error: any) { 42 | window.showErrorMessage(error.message); 43 | throw error; 44 | } 45 | 46 | const $ = cheerioModule.load(data); 47 | let choices = new Map(); 48 | $("#readerlist ul a").each(function (_i, ele) { 49 | choices.set($(ele).text(), self.baseURL + $(ele).prop("href")); 50 | }); 51 | return choices; 52 | } 53 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # shadow-reader 2 | 3 | vscode摸鱼看书插件,老板站在身后也不发现不了 4 | 5 | ## Features 6 | - 支持状态栏显示 7 | - 支持本地文本阅读 8 | - 其他编码格式自动转码(支持格式GB18030、Big5、UTF-8、UTF-16、UTF-32等) 9 | - 支持向后搜索内容 10 | - 支持网络书籍(当前支持[笔趣阁](https://www.biqugee.com/),[采墨阁](https://www.caimoge.net/)) 11 | 12 | ## Install 13 | vscode插件市场,搜索`shadow reader`,安装 14 | 15 | ## Quick Start 16 | ### 主菜单(2选1) 17 | 1. ctrl+shift+p,搜索`shadowReader:开始工作` 18 | ![feature X](./images/start.jpg) 19 | 2. 点击右上角的图标 20 | ![feature X](./images/start_icon.png) 21 | 22 | 主菜单如下 23 | ![feature X](./images/main.jpg) 24 | 25 | ### 新增书籍 26 | 1. 选择`添加新书籍` 27 | 2. 本地书籍:选择`本地书籍`,选择文件,并起一个好记的名字吧 28 | ![feature X](./images/new_book.jpg) 29 | ![feature X](./images/nick.png) 30 | 3. 网络书籍:选择`网络书籍`,搜索名字(*为随机几个),选择对应书籍 31 | ![feature X](./images/fuzzy_online.png) 32 | ![feature X](./images/online_list.png) 33 | 34 | ### 开始阅读 35 | 1. 在主菜单选择`开始阅读`,选择刚添加的书名`活着` 36 | ![feature X](./images/select_book.jpg) 37 | 2. 使用快捷键,上一页`alt+,`,下一页`alt+.`,老板键`alt+/` 38 | ![feature X](./images/show_text.jpg) 39 | 40 | ### 删除书籍 41 | 1. 在主菜单选择`删除书籍`,选择书名`活着`,即可删除 42 | 43 | ### 自动老板键 44 | 1. 若长时间不操作,会自动使用老板键,当前显示文本为`Hello World` 45 | 2. 再次使用`alt+/`,可返回原文本 46 | 47 | ### 按内容向后搜索 48 | 1. 点击状态栏,输入搜索文本 49 | ![feature X](./images/search.jpg) 50 | 51 | ## Extension Settings 52 | * `shadowReader.pageSize`:每次最多显示字数(默认50) 53 | * `shadowReader.onlineBookURL`:在线书源,当前已支持笔趣阁 54 | * `shadowReader.hiddenTime`:自动切换至老板状态时间(单位秒,默认30) 55 | * 修改快捷键:首选项 -- 键盘快捷方式 56 | ![feature X](./images/keybind.jpg) 57 | 58 | ## Design Mind 59 | - 专注vscode插件 60 | - 专注隐蔽性、易用性 61 | 62 | ## Known Issues 63 | - 上下一页的无反应(暂不清楚形成原因,可通过重新设置快捷键解决) 64 | - 笔趣阁在梯子下无法访问(请关闭梯子即可) 65 | - certificate expire [vscode issue](https://github.com/microsoft/vscode/issues/136787),可通过禁用vscode证书验证实现![feature X](./images/certificate_expire.png) 66 | 67 | ## Future Feature 68 | - 其他格式支持(比如.epub) 69 | - 其他隐藏显示手段 70 | 71 | ## Inspire 72 | 本插件灵感来自于[Thief-Book-VSCode](https://github.com/cteamx/Thief-Book-VSCode),二者区别: 73 | 74 | 区别 | Thief-Book-VSCode | shadow reader 75 | ---- | ---- | ---- 76 | 大文件 | 全部加载 | 部分加载 77 | 支持编码 | utf8 | GB\Big5\UTF 78 | 支持书量 | 1本 | 多本 79 | 全文搜索 | 不支持 | 向后 80 | 在线书籍 | 不支持 | 支持 81 | 更新时间 | 2019 | 2021 82 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, mkdirSync, createReadStream, createWriteStream, copyFileSync, unlink } from "fs"; 2 | import { ExtensionContext, window } from "vscode"; 3 | import chardet = require('chardet'); 4 | import iconv = require('iconv-lite'); 5 | import path = require('path'); 6 | import stream = require('stream'); 7 | 8 | export const bookLibraryKey = "bookList"; 9 | 10 | // check target file is decode == utf8 or not 11 | // if decode != UTF-16 LE, auto detect its decode, and store in disk with UTF-16 LE 12 | export function checkFileDecodeOrConvert(context: ExtensionContext, filePath: string, newFileName: string) { 13 | // detect decode 14 | let decode = detectFileDecode(filePath); 15 | let newFilePath = path.join(context.globalStorageUri.fsPath, newFileName); 16 | 17 | // convert decode 18 | if (!existsSync(context.globalStorageUri.fsPath)) { 19 | mkdirSync(context.globalStorageUri.fsPath); 20 | } 21 | if (decode !== "UTF-32 LE") { 22 | convertFileDecode(filePath, decode, newFilePath); 23 | } else { 24 | copyFileSync(filePath, newFilePath); 25 | } 26 | 27 | // store book list 28 | // context.globalState.update(bookLibraryKey, new Map()); 29 | let bookLibraryDictString = context.globalState.get(bookLibraryKey, "{}"); 30 | let bookLibraryDict = JSON.parse(bookLibraryDictString); 31 | bookLibraryDict[newFileName] = newFilePath; 32 | context.globalState.update(bookLibraryKey, JSON.stringify(bookLibraryDict)); 33 | 34 | window.showInformationMessage("添加成功"); 35 | } 36 | 37 | function detectFileDecode(filePath: string): string { 38 | let decode = chardet.detectFileSync(filePath, { sampleSize: 128 }); 39 | if (!decode) { 40 | throw new Error("当前编码不支持"); 41 | } 42 | return decode.toString(); 43 | } 44 | 45 | function convertFileDecode(oldfilePath: string, decode: string, newFilePath: string) { 46 | createReadStream(oldfilePath) 47 | .pipe(iconv.decodeStream(decode)) 48 | .pipe(iconv.encodeStream("utf32-le")) 49 | .pipe(createWriteStream(newFilePath)); 50 | } 51 | 52 | export function deleteFile(context: ExtensionContext, fileName: string) { 53 | let bookLibraryDictString = context.globalState.get(bookLibraryKey, "{}"); 54 | let bookLibraryDict = JSON.parse(bookLibraryDictString); 55 | // TODO delete history 56 | // context.globalState.update(bookLibraryDict[fileName], undefined); 57 | delete bookLibraryDict[fileName]; 58 | context.globalState.update(bookLibraryKey, JSON.stringify(bookLibraryDict)); 59 | 60 | let diskFilePath = path.join(context.globalStorageUri.fsPath, fileName); 61 | unlink(diskFilePath, () => {}); 62 | } 63 | -------------------------------------------------------------------------------- /src/parse/txt.ts: -------------------------------------------------------------------------------- 1 | import { openSync, closeSync, readSync, fstatSync } from "fs"; 2 | import iconv = require('iconv-lite'); 3 | import { BookKind, BookStore } from "./model"; 4 | import { Parser } from "./interface"; 5 | 6 | export class TxtFileParser implements Parser { 7 | private fd: number; 8 | private readonly stringMaxSize: number = 4; 9 | private readonly encoding: string = "utf32-le"; 10 | private totalByteSize: number; 11 | private readedCount: number; 12 | private lastPageSize: number = 0; 13 | 14 | constructor (bookPath: string, readedCount: number) { 15 | this.fd = openSync(bookPath, 'r'); 16 | this.totalByteSize = fstatSync(this.fd).size; 17 | this.readedCount = readedCount; 18 | } 19 | 20 | getPage(size: number, start: number): [string, number] { 21 | this.lastPageSize = size; 22 | let bufferSize = this.stringMaxSize * size; 23 | let buffer = Buffer.alloc(bufferSize); 24 | 25 | let readAllBytes = readSync(this.fd, buffer, 0, bufferSize, start); 26 | if (readAllBytes === 0) { 27 | return ["", 0]; 28 | } 29 | 30 | let showText = iconv.decode(buffer, this.encoding); 31 | let lineBreakPosition = showText.indexOf('\n'); 32 | if (lineBreakPosition !== -1) { 33 | bufferSize = ( lineBreakPosition + 1) * this.stringMaxSize; 34 | showText = showText.slice(0, lineBreakPosition); 35 | } 36 | 37 | showText = showText.replace(/\r/g, '').trim(); 38 | return [showText, bufferSize]; 39 | } 40 | 41 | async getNextPage(pageSize: number): Promise { 42 | while (this.readedCount < this.totalByteSize) { 43 | let [showText, bufferSize] = this.getPage(pageSize, this.readedCount); 44 | this.readedCount += bufferSize; 45 | if (showText.length === 0) { 46 | continue; 47 | } 48 | return showText; 49 | } 50 | return ''; 51 | } 52 | 53 | getPrevPage(pageSize: number): Promise { 54 | this.readedCount -= pageSize * 2 * this.stringMaxSize; 55 | if (this.readedCount < 0) { 56 | this.readedCount = 0; 57 | } 58 | return this.getNextPage(pageSize); 59 | } 60 | 61 | close(): void { 62 | closeSync(this.fd); 63 | } 64 | 65 | getPercent(): string { 66 | return `${(this.readedCount / this.totalByteSize * 100).toFixed(2)}%`; 67 | } 68 | 69 | getPersistHistory(): BookStore { 70 | return { 71 | kind: BookKind.local, 72 | readedCount: this.readedCount - this.lastPageSize * this.stringMaxSize, 73 | }; 74 | } 75 | } -------------------------------------------------------------------------------- /src/crawler/biqu.ts: -------------------------------------------------------------------------------- 1 | import cheerioModule = require("cheerio"); 2 | import axios from "axios"; 3 | import iconv = require('iconv-lite'); 4 | import https = require('https'); 5 | import { window } from "vscode"; 6 | import { Craweler } from "./interface"; 7 | 8 | const ignoreSSL = axios.create({ 9 | httpsAgent: new https.Agent({ 10 | rejectUnauthorized: false 11 | }) 12 | }); 13 | 14 | function sleep(delay: number) { 15 | return new Promise(reslove => { 16 | setTimeout(reslove, delay) 17 | }) 18 | } 19 | 20 | export class BiquCrawler implements Craweler { 21 | 22 | private readonly baseURL = "https://www.biqugee6.com"; 23 | private readonly defaultEncode = "utf-8"; 24 | 25 | async searchBook(keyWord: string): Promise> { 26 | let data: string = ""; 27 | let self = this; 28 | let count = 0; 29 | const retryCount = 5; 30 | let result; 31 | while (count < retryCount) { 32 | try { 33 | const response = await ignoreSSL.get(self.baseURL + "/search.php", { 34 | params: { keyword: keyWord } 35 | }); 36 | result = response; 37 | if (response.data.indexOf("Verify") !== -1) { 38 | count++; 39 | await sleep(1000); 40 | continue; 41 | } 42 | data = response.data; 43 | break; 44 | } catch (error: any) { 45 | window.showErrorMessage(error.message); 46 | throw error; 47 | } 48 | } 49 | 50 | if (count >= retryCount) { 51 | let error_msg = "遭遇验证码次数过多,稍后再试吧"; 52 | window.showErrorMessage(error_msg); 53 | throw new Error(error_msg); 54 | } 55 | 56 | const $ = cheerioModule.load(data); 57 | let choices = new Map(); 58 | $("a.result-game-item-title-link").each(function (_i, ele) { 59 | choices.set($(ele).prop("title"), self.baseURL + $(ele).prop("href")); 60 | }); 61 | if (choices.size == 0) { 62 | console.log(result) 63 | } 64 | return choices; 65 | 66 | } 67 | 68 | async findChapterURL(url: string): Promise> { 69 | let data: string; 70 | let self = this; 71 | try { 72 | const response = await axios.get(url, {responseType: "arraybuffer"}); 73 | data = iconv.decode(response.data, this.defaultEncode); 74 | } catch (error: any) { 75 | window.showErrorMessage(error.message); 76 | throw error; 77 | } 78 | 79 | const $ = cheerioModule.load(data); 80 | let choices = new Map(); 81 | $("#list a").each(function (_i, ele) { 82 | choices.set($(ele).text(), self.baseURL + $(ele).prop("href")); 83 | }); 84 | return choices; 85 | } 86 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shadow-reader", 3 | "displayName": "shadow reader", 4 | "description": "摸鱼划水看书,十分隐蔽", 5 | "version": "0.8.2", 6 | "publisher": "rainbroadcast", 7 | "engines": { 8 | "vscode": "^1.54.0" 9 | }, 10 | "icon": "images/icon.jpeg", 11 | "categories": [ 12 | "Other" 13 | ], 14 | "activationEvents": [ 15 | "onCommand:shadowReader.start" 16 | ], 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/igzhang/shadowReader.git" 20 | }, 21 | "homepage": "https://github.com/igzhang/shadowReader/blob/main/README.md", 22 | "main": "./out/extension.js", 23 | "contributes": { 24 | "configuration": { 25 | "title": "shadow reader", 26 | "properties": { 27 | "shadowReader.pageSize": { 28 | "type": "number", 29 | "default": 50, 30 | "description": "每次最多显示字数" 31 | }, 32 | "shadowReader.onlineBookURL": { 33 | "type": "string", 34 | "default": "https://www.biqugee6.com", 35 | "enum": [ 36 | "https://www.caimoge.net", 37 | "https://www.biqugee6.com" 38 | ], 39 | "enumDescriptions": [ 40 | "采墨阁", 41 | "笔趣E" 42 | ], 43 | "description": "在线书源" 44 | }, 45 | "shadowReader.hiddenTime": { 46 | "type": "number", 47 | "default": 30, 48 | "description": "自动隐藏时间" 49 | } 50 | } 51 | }, 52 | "keybindings": [ 53 | { 54 | "command": "shadowReader.getNextPage", 55 | "key": "alt+." 56 | }, 57 | { 58 | "command": "shadowReader.getPrevPage", 59 | "key": "alt+," 60 | }, 61 | { 62 | "command": "shadowReader.showBossInfo", 63 | "key": "alt+/" 64 | } 65 | ], 66 | "commands": [ 67 | { 68 | "command": "shadowReader.start", 69 | "title": "开始工作", 70 | "category": "shadowReader", 71 | "icon": "$(book)" 72 | } 73 | ], 74 | "menus": { 75 | "editor/title": [ 76 | { 77 | "command": "shadowReader.start", 78 | "alt": "markdown.showPreviewToSide", 79 | "group": "navigation" 80 | } 81 | ] 82 | }, 83 | "capabilities" : { 84 | "hoverProvider" : "true" 85 | } 86 | }, 87 | "scripts": { 88 | "vscode:prepublish": "npm run compile", 89 | "compile": "tsc -p ./", 90 | "watch": "tsc -watch -p ./", 91 | "pretest": "npm run compile && npm run lint", 92 | "lint": "eslint src --ext ts", 93 | "test": "node ./out/test/runTest.js" 94 | }, 95 | "devDependencies": { 96 | "@types/glob": "^7.1.3", 97 | "@types/mocha": "^8.0.4", 98 | "@types/node": "^12.11.7", 99 | "@types/vscode": "^1.54.0", 100 | "@typescript-eslint/eslint-plugin": "^4.14.1", 101 | "@typescript-eslint/parser": "^4.14.1", 102 | "eslint": "^7.19.0", 103 | "glob": "^7.1.6", 104 | "mocha": "^8.2.1", 105 | "typescript": "^4.1.3", 106 | "vscode-test": "^1.5.0" 107 | }, 108 | "dependencies": { 109 | "axios": "^0.21.1", 110 | "chardet": "^1.3.0", 111 | "cheerio": "^1.0.0-rc.5", 112 | "iconv-lite": "^0.6.2" 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/read.ts: -------------------------------------------------------------------------------- 1 | import { workspace, ExtensionContext } from "vscode"; 2 | import { setStatusBarMsg } from "./util"; 3 | import { BookKind, BookStore } from "./parse/model"; 4 | import { Parser } from "./parse/interface"; 5 | import { TxtFileParser } from "./parse/txt"; 6 | import { CrawelerDomains } from "./const"; 7 | import { BiquWebParser } from "./parse/biqu"; 8 | import { CaimoWebParser } from "./parse/caimo"; 9 | 10 | let bookPath: string = ""; 11 | let parser: Parser; 12 | const readEOFTip = ""; 13 | 14 | 15 | function loadParser(context: ExtensionContext, bookPath: string): Parser { 16 | let store = context.globalState.get(bookPath, 0); 17 | 18 | let bookStore: BookStore; 19 | // compatible old version 20 | if (typeof store === "number") { 21 | bookStore = { 22 | kind: BookKind.local, 23 | readedCount: store, 24 | }; 25 | } else { 26 | bookStore = store as BookStore; 27 | } 28 | 29 | switch (bookStore.kind) { 30 | case BookKind.local: 31 | return new TxtFileParser(bookPath, bookStore.readedCount); 32 | 33 | case BookKind.online: 34 | if(bookStore.sectionPath?.startsWith(CrawelerDomains.get("biquURL"))) { 35 | return new BiquWebParser(bookStore.sectionPath, bookStore.readedCount, bookPath); 36 | } else if(bookStore.sectionPath?.startsWith(CrawelerDomains.get("caimoURL"))) { 37 | return new CaimoWebParser(bookStore.sectionPath, bookStore.readedCount, bookPath); 38 | } 39 | throw new Error("book url is not supported"); 40 | default: 41 | throw new Error("book kind is not supported"); 42 | } 43 | } 44 | 45 | export async function readNextLine(context: ExtensionContext): Promise { 46 | let pageSize: number = workspace.getConfiguration().get("shadowReader.pageSize"); 47 | let content = await parser.getNextPage(pageSize); 48 | if (content.length === 0) { 49 | return readEOFTip; 50 | } 51 | let percent = parser.getPercent(); 52 | context.globalState.update(bookPath, parser.getPersistHistory()); 53 | return `${content} ${percent}`; 54 | } 55 | 56 | export async function readPrevLine(context: ExtensionContext): Promise { 57 | let pageSize: number = workspace.getConfiguration().get("shadowReader.pageSize"); 58 | let content = await parser.getPrevPage(pageSize); 59 | let percent = parser.getPercent(); 60 | context.globalState.update(bookPath, parser.getPersistHistory()); 61 | return `${content} ${percent}`; 62 | } 63 | 64 | export function closeAll(): void { 65 | if (parser) { 66 | parser.close(); 67 | } 68 | } 69 | 70 | export function loadFile(context: ExtensionContext, newfilePath: string) { 71 | if (parser) { 72 | parser.close(); 73 | } 74 | parser = loadParser(context, newfilePath); 75 | bookPath = newfilePath; 76 | let text = readNextLine(context).then(text => { 77 | setStatusBarMsg(text); 78 | }); 79 | } 80 | 81 | export async function searchContentToEnd(context: ExtensionContext, keyword: string): Promise { 82 | let keywordIndex = 0; 83 | let preLineEndMatch = false; 84 | let pageSize: number = workspace.getConfiguration().get("shadowReader.pageSize"); 85 | while (true) { 86 | let content = await parser.getNextPage(pageSize); 87 | if (content.length === 0) { 88 | break; 89 | } 90 | 91 | for (let char of content) { 92 | if (char === keyword[keywordIndex]) { 93 | keywordIndex++; 94 | if (keywordIndex === keyword.length) { 95 | if (preLineEndMatch) { 96 | return await readPrevLine(context); 97 | } else { 98 | let percent = parser.getPercent(); 99 | context.globalState.update(bookPath, parser.getPersistHistory()); 100 | return `${content} ${percent}`;; 101 | } 102 | } 103 | } else { 104 | keywordIndex = 0; 105 | } 106 | } 107 | 108 | // between two lines 109 | if (keywordIndex !== 0) { 110 | preLineEndMatch = true; 111 | } 112 | } 113 | return readEOFTip; 114 | } 115 | -------------------------------------------------------------------------------- /src/parse/caimo.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import cheerioModule = require("cheerio"); 3 | import iconv = require('iconv-lite'); 4 | import { window } from "vscode"; 5 | import { Parser } from "./interface"; 6 | import { BookKind, BookStore } from "./model"; 7 | import { CrawelerDomains } from "../const"; 8 | 9 | export class CaimoWebParser implements Parser { 10 | 11 | private prevPageURL: string = ""; 12 | private nextPageURL: string = ""; 13 | private currentPageURL: string; 14 | private readedCount: number; 15 | private cacheText = ""; 16 | private readonly defaultEncode = "utf-8"; 17 | private lastPageSize = 0; 18 | private title = ""; 19 | private indexPageURL: string; 20 | private readonly baseURL = CrawelerDomains.get("caimoURL"); 21 | 22 | constructor(currentPageURL: string, readedCount: number, indexPageURL: string) { 23 | this.currentPageURL = currentPageURL; 24 | this.readedCount = readedCount; 25 | this.indexPageURL = indexPageURL; 26 | } 27 | 28 | close(): void {}; 29 | 30 | private async fetchPage(pageURL: string): Promise { 31 | this.currentPageURL = pageURL; 32 | let data: string; 33 | try { 34 | const response = await axios.get(pageURL, { responseType: "arraybuffer" }); 35 | data = iconv.decode(response.data, this.defaultEncode); 36 | } catch (e: any) { 37 | window.showErrorMessage(e.message); 38 | throw e; 39 | } 40 | 41 | const $ = cheerioModule.load(data); 42 | 43 | let html = $("#content").html(); 44 | if (!html) { 45 | window.showErrorMessage("爬不到内容啦"); 46 | return; 47 | } 48 | 49 | this.cacheText = html.replace(/

/g, '').replace(/<\/p>/g, '\n').replace(/

.*<\/div>/g, '').trim(); 50 | this.title = $(".title em").text(); 51 | 52 | this.prevPageURL = this.baseURL + $("#prev_url").prop("href"); 53 | this.nextPageURL = this.baseURL + $("#next_url").prop("href"); 54 | } 55 | 56 | async getCacheText(start: number, pageSize: number): Promise { 57 | this.lastPageSize = pageSize; 58 | return this.cacheText.slice(start, start + pageSize); 59 | } 60 | 61 | async getNextPage(pageSize: number): Promise { 62 | if (this.cacheText.length === 0) { 63 | await this.fetchPage(this.currentPageURL); 64 | } 65 | 66 | if (this.readedCount === this.cacheText.length) { 67 | if (this.nextPageURL === this.indexPageURL) { 68 | return "已无新章节"; 69 | } 70 | await this.fetchPage(this.nextPageURL); 71 | this.readedCount = 0; 72 | } 73 | 74 | this.lastPageSize = pageSize; 75 | let showText = this.cacheText.slice(this.readedCount, this.readedCount + pageSize); 76 | 77 | let lineBreakPosition = showText.indexOf("\n"); 78 | if (lineBreakPosition === -1) { 79 | this.readedCount += pageSize; 80 | }else{ 81 | this.readedCount += lineBreakPosition + 1; 82 | showText = showText.slice(0, lineBreakPosition); 83 | } 84 | 85 | if (this.readedCount >= this.cacheText.length) { 86 | this.readedCount = this.cacheText.length; 87 | } 88 | return showText; 89 | } 90 | 91 | async getPrevPage(pageSize: number): Promise { 92 | if (this.cacheText.length === 0) { 93 | await this.fetchPage(this.currentPageURL); 94 | } 95 | 96 | if (this.readedCount - pageSize === 0 && this.prevPageURL !== this.indexPageURL) { 97 | await this.fetchPage(this.prevPageURL); 98 | this.readedCount = this.cacheText.length + pageSize; 99 | } 100 | 101 | this.readedCount = this.readedCount - pageSize * 2; 102 | if (this.readedCount < 0) { 103 | this.readedCount = 0; 104 | } 105 | return this.getNextPage(pageSize); 106 | } 107 | 108 | getPercent(): string { 109 | return `${this.title}`; 110 | } 111 | 112 | getPersistHistory(): BookStore { 113 | return { 114 | kind: BookKind.online, 115 | readedCount: this.readedCount - this.lastPageSize, 116 | sectionPath: this.currentPageURL, 117 | }; 118 | }; 119 | } -------------------------------------------------------------------------------- /src/parse/biqu.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import cheerioModule = require("cheerio"); 3 | import iconv = require('iconv-lite'); 4 | import { window } from "vscode"; 5 | import { Parser } from "./interface"; 6 | import { BookKind, BookStore } from "./model"; 7 | import { CrawelerDomains } from "../const"; 8 | 9 | export class BiquWebParser implements Parser { 10 | 11 | private prevPageURL: string = ""; 12 | private nextPageURL: string = ""; 13 | private currentPageURL: string; 14 | private readedCount: number; 15 | private cacheText = ""; 16 | private readonly defaultEncode = "utf-8"; 17 | private lastPageSize = 0; 18 | private title = ""; 19 | private indexPageURL: string; 20 | private readonly baseURL = CrawelerDomains.get("biquURL"); 21 | 22 | constructor(currentPageURL: string, readedCount: number, indexPageURL: string) { 23 | this.currentPageURL = currentPageURL; 24 | this.readedCount = readedCount; 25 | this.indexPageURL = indexPageURL; 26 | } 27 | 28 | close(): void {}; 29 | 30 | private async fetchPage(pageURL: string): Promise { 31 | this.currentPageURL = pageURL; 32 | let data: string; 33 | try { 34 | const response = await axios.get(pageURL, { responseType: "arraybuffer" }); 35 | data = iconv.decode(response.data, this.defaultEncode); 36 | } catch (e: any) { 37 | window.showErrorMessage(e.message); 38 | throw e; 39 | } 40 | 41 | const $ = cheerioModule.load(data); 42 | let html = $("#content").html(); 43 | if (!html) { 44 | window.showErrorMessage("爬不到内容啦"); 45 | return; 46 | } 47 | this.cacheText = html.replace(/

.*<\/p>/g, '').replace(/

/g, '\n').trim(); 48 | this.title = $("h1").text(); 49 | $(".bottem1>a").each((i, ele) => { 50 | switch (i) { 51 | case 1: 52 | this.prevPageURL = `${this.baseURL}${$(ele).prop("href")}`; 53 | break; 54 | 55 | case 3: 56 | this.nextPageURL = `${this.baseURL}${$(ele).prop("href")}`; 57 | break; 58 | 59 | default: 60 | break; 61 | } 62 | }); 63 | } 64 | 65 | async getCacheText(start: number, pageSize: number): Promise { 66 | this.lastPageSize = pageSize; 67 | return this.cacheText.slice(start, start + pageSize); 68 | } 69 | 70 | async getNextPage(pageSize: number): Promise { 71 | if (this.cacheText.length === 0) { 72 | await this.fetchPage(this.currentPageURL); 73 | } 74 | 75 | if (this.readedCount === this.cacheText.length) { 76 | if (this.nextPageURL === this.indexPageURL) { 77 | return "已无新章节"; 78 | } 79 | await this.fetchPage(this.nextPageURL); 80 | this.readedCount = 0; 81 | } 82 | 83 | this.lastPageSize = pageSize; 84 | let showText = this.cacheText.slice(this.readedCount, this.readedCount + pageSize); 85 | 86 | let lineBreakPosition = showText.indexOf("\n"); 87 | if (lineBreakPosition === -1) { 88 | this.readedCount += pageSize; 89 | }else{ 90 | this.readedCount += lineBreakPosition + 1; 91 | showText = showText.slice(0, lineBreakPosition); 92 | } 93 | 94 | if (this.readedCount >= this.cacheText.length) { 95 | this.readedCount = this.cacheText.length; 96 | } 97 | return showText; 98 | } 99 | 100 | async getPrevPage(pageSize: number): Promise { 101 | if (this.cacheText.length === 0) { 102 | await this.fetchPage(this.currentPageURL); 103 | } 104 | 105 | if (this.readedCount - pageSize === 0 && this.prevPageURL !== this.indexPageURL) { 106 | await this.fetchPage(this.prevPageURL); 107 | this.readedCount = this.cacheText.length + pageSize; 108 | } 109 | 110 | this.readedCount = this.readedCount - pageSize * 2; 111 | if (this.readedCount < 0) { 112 | this.readedCount = 0; 113 | } 114 | return this.getNextPage(pageSize); 115 | } 116 | 117 | getPercent(): string { 118 | return `${this.title}`; 119 | } 120 | 121 | getPersistHistory(): BookStore { 122 | return { 123 | kind: BookKind.online, 124 | readedCount: this.readedCount - this.lastPageSize, 125 | sectionPath: this.currentPageURL, 126 | }; 127 | }; 128 | } -------------------------------------------------------------------------------- /src/menu.ts: -------------------------------------------------------------------------------- 1 | import { window, ExtensionContext, workspace } from "vscode"; 2 | import path = require('path'); 3 | import { checkFileDecodeOrConvert, bookLibraryKey, deleteFile } from "./store"; 4 | import { loadFile, searchContentToEnd } from "./read"; 5 | import { setStatusBarMsg } from "./util"; 6 | import { Craweler } from "./crawler/interface"; 7 | import { CrawelerDomains } from "./const"; 8 | import { BiquCrawler } from "./crawler/biqu"; 9 | import { CaimoCrawler } from "./crawler/caimo"; 10 | import { BookKind } from "./parse/model"; 11 | 12 | let bookLibraryDict: object = {}; 13 | 14 | enum Menu { 15 | readBook = "开始阅读", 16 | newBook = "添加新书籍", 17 | newLocalBook = "本地书籍", 18 | deleteBook = "删除书籍", 19 | newOnlineBook = "网络书籍", 20 | } 21 | 22 | function hasKey(obj: O, key: keyof any): key is keyof O { 23 | return key in obj; 24 | } 25 | 26 | export async function showMainMenu(context: ExtensionContext) { 27 | let firstChoice = await window.showQuickPick([Menu.readBook, Menu.newBook, Menu.deleteBook], { 28 | matchOnDescription: true, 29 | }); 30 | let bookChoice: string | undefined; 31 | switch (firstChoice) { 32 | case Menu.readBook: 33 | bookChoice = await showBookLibraryList(context); 34 | if (bookChoice && hasKey(bookLibraryDict, bookChoice)) { 35 | loadFile(context, bookLibraryDict[bookChoice]); 36 | } 37 | break; 38 | 39 | case Menu.newBook: 40 | newBookMenu(context); 41 | break; 42 | 43 | case Menu.deleteBook: 44 | bookChoice = await showBookLibraryList(context); 45 | if (bookChoice) { 46 | deleteFile(context, bookChoice); 47 | window.showInformationMessage("删除成功"); 48 | } 49 | break; 50 | 51 | default: 52 | break; 53 | } 54 | } 55 | 56 | function newOnlineCraweler(): Craweler { 57 | let bookURL = workspace.getConfiguration().get("shadowReader.onlineBookURL"); 58 | switch (bookURL) { 59 | case CrawelerDomains.get("caimoURL"): 60 | return new CaimoCrawler(); 61 | case CrawelerDomains.get("biquURL"): 62 | return new BiquCrawler(); 63 | default: 64 | return new CaimoCrawler(); 65 | } 66 | } 67 | 68 | async function newBookMenu(context: ExtensionContext) { 69 | let newBookChoice = await window.showQuickPick([Menu.newLocalBook, Menu.newOnlineBook ], { 70 | matchOnDescription: true, 71 | }); 72 | switch (newBookChoice) { 73 | case Menu.newLocalBook: 74 | window.showOpenDialog().then((filePaths) => { 75 | if (filePaths && filePaths.length > 0) { 76 | window.showInputBox({ 77 | value: path.basename(filePaths[0].fsPath), 78 | placeHolder: "别名", 79 | prompt: "起个名子吧(重名会覆盖哦)" 80 | }).then( 81 | nickName => { 82 | if (nickName) { 83 | checkFileDecodeOrConvert(context, filePaths[0].fsPath, nickName); 84 | } 85 | } 86 | ); 87 | } 88 | }); 89 | break; 90 | 91 | case Menu.newOnlineBook: 92 | let onlineBookURL: string | undefined = workspace.getConfiguration().get("shadowReader.onlineBookURL"); 93 | if (!onlineBookURL) { 94 | window.showErrorMessage("onlineBookURL未配置"); 95 | return; 96 | } 97 | window.showInputBox({ 98 | value: "", 99 | prompt: "要搜索的书名" 100 | }).then(async bookFuzzyName => { 101 | if (bookFuzzyName) { 102 | let crawler: Craweler = newOnlineCraweler(); 103 | let bookDict = await crawler.searchBook(bookFuzzyName); 104 | window.showQuickPick(Array.from(bookDict.keys()), {matchOnDescription: true}).then(async value => { 105 | if (value) { 106 | let bookURL = bookDict.get(value); 107 | let chapterURLDict = await crawler.findChapterURL(bookURL); 108 | window.showQuickPick(Array.from(chapterURLDict.keys()), {matchOnDescription: true}).then( startChapter => { 109 | if(startChapter) { 110 | let bookLibraryDictString = context.globalState.get(bookLibraryKey, "{}"); 111 | let bookLibraryDict = JSON.parse(bookLibraryDictString); 112 | bookLibraryDict[value] = bookURL; 113 | context.globalState.update(bookLibraryKey, JSON.stringify(bookLibraryDict)); 114 | context.globalState.update(bookURL, { 115 | kind: BookKind.online, 116 | readedCount: 0, 117 | sectionPath: chapterURLDict.get(startChapter), 118 | }); 119 | window.showInformationMessage("添加成功"); 120 | } 121 | }); 122 | } 123 | }); 124 | } 125 | }); 126 | 127 | break; 128 | 129 | default: 130 | break; 131 | } 132 | } 133 | 134 | async function showBookLibraryList(context: ExtensionContext): Promise { 135 | let bookLibraryDictString = context.globalState.get(bookLibraryKey, "{}"); 136 | bookLibraryDict = JSON.parse(bookLibraryDictString); 137 | return await window.showQuickPick(Object.keys(bookLibraryDict), { 138 | matchOnDescription: true, 139 | }); 140 | } 141 | 142 | export function showSearchKeywordBox(context: ExtensionContext) { 143 | window.showInputBox({ 144 | placeHolder: "注意:会自动跳转", 145 | prompt: "按照内容向后搜索" 146 | }).then( 147 | keyWord => { 148 | if (keyWord) { 149 | let text = searchContentToEnd(context, keyWord).then(text => { 150 | setStatusBarMsg(text); 151 | window.showInformationMessage("搜索完成"); 152 | }); 153 | } 154 | } 155 | ); 156 | } --------------------------------------------------------------------------------