Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ dist
total.svg
src/plugins/index.ts
.history
broken-sites-report.json
broken-sites-report.json
electron/dist-electron

# LLM skills
.agent/
52 changes: 39 additions & 13 deletions docs/docs_vi.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,28 +55,54 @@ export default PLUGINS;

## Hướng dẫn Debug (Gỡ lỗi)

Trong quá trình phát triển Plugin, chắc chắn bạn sẽ cần kiểm tra xem code của mình có hoạt động đúng hay không. Môi trường phát triển của Repository này đã tích hợp sẵn một giao diện Web (Web UI) để mô phỏng hoạt động của ứng dụng LNReader ngay trên trình duyệt.
Trong quá trình phát triển Plugin, bạn sẽ cần kiểm tra xem code của mình có hoạt động đúng hay không. Repository này cung cấp 3 phương pháp chính để gỡ lỗi, từ nhẹ nhất đến giống thực tế nhất:

### 1. Khởi chạy môi trường test cục bộ (Local Web Interface)
### 1. WebUI Playground (Khởi chạy trên trình duyệt web)

Mở terminal và chạy lệnh sau:
Đây là môi trường mô phỏng nhanh nhất, sử dụng trực tiếp trình duyệt (Chrome, Edge...) của bạn. Thích hợp để test logic parse, lọc HTML nhanh chóng.

Mở terminal ở thư mục gốc và chạy:
```bash
npm run dev:start
```

- Lệnh này sẽ tự động biên dịch toàn bộ các files và khởi chạy một Web Server mô phỏng.
- Bạn có thể truy cập vào địa chỉ được hiển thị trên console (thường là `http://localhost:5173` hoặc `http://localhost:3000`).
- Giao diện này cho phép bạn tương tác trực tiếp: duyệt danh sách truyện mới, tìm kiếm, xem danh sách chương truyện và nội dung trang đọc giống như một người dùng đang sử dụng LNReader.
- Lệnh này sẽ tự động biên dịch và khởi chạy một Web Server. Truy cập vào `http://localhost:3000`.
- Bạn có thể thao tác: duyệt truyện, tìm kiếm, đọc chương.
- Gỡ lỗi: Ấn phím **F12** để mở Developer Tools, xem tab `Console` cho các lệnh `console.log()` hoặc `Network` để xem các request bị chặn.
- **Hạn chế:** Bị giới hạn bởi CORS của trình duyệt và không có cấu trúc Cookie đồng bộ mạnh mẽ như ứng dụng thật.

### 2. Electron Playground (Môi trường Desktop)

**Khuyên dùng!** Đây là môi trường mạnh mẽ mô phỏng chính xác React Native app (hỗ trợ lưu trữ persistent cookie, sandbox, fetch qua NodeJS). Giúp bạn dễ dàng test các trang web chặn Cloudflare/DDOS phức tạp.

Mở terminal ở thư mục gốc, di chuyển vào thư mục `electron` và khởi động Electron:
```bash
cd electron
npm run dev
```

- Ứng dụng Desktop sẽ mở ra. Bạn có thể duyệt truyện và parse chapter tương tự WebUI nhưng ổn định hơn.
- Cung cấp tính năng **Spawn New Tab** (`Ctrl+T` hoặc `Cmd+T`) giả lập một WebView của LNReader. Tại đây, bạn có thể tự tay giải Captcha/Cloudflare, các cookie bảo mật sẽ được tự động đồng bộ xuống cho các request `fetch` của plugin.
- Gỡ lỗi: DevTools (Nếu gỡ lỗi WebView, ấn icon Debug ở cuối thanh địa chỉ)

### 3. serve:dev (Kiểm thử trực tiếp trên ứng dụng LNReader)

### 2. Sử dụng `console.log`
Dành cho bước kiểm tra cuối cùng trước khi hoàn thiện. Phương pháp này biên dịch plugin và tạo ra một local server. Bạn sẽ add link của local server này vào app LNReader trên điện thoại để test thực tế.

- Vì code của bạn đang chạy thông qua trình duyệt ở giao diện Web, bạn hoàn toàn có thể sử dụng `console.log`, `console.warn`, hoặc `console.error` ngay bên trong các hàm xử lý của Plugin (`popularNovels`, `parseNovel`, `parseChapter`,...).
- **Cách xem:** Mô phỏng các thao tác tương ứng trên Web UI (ví dụ ấn vào xem truyện), ấn phím **F12** để mở Developer Tools của trình duyệt (Chrome, Edge...), chuyển sang tab `Console` và theo dõi quá trình in kết quả.
Chuẩn bị nội dung cho file `.env`
```
USER_CONTENT_BASE=http://<IP-của-máy-tính>:3000
```

### 3. Debug lỗi kẹt tại Fetch API hoặc Parse HTML
Mở terminal ở thư mục gốc và chạy:
```bash
npm run serve:dev
```

- Nếu truyện không tải được danh sách, hãy mở **tab Network** trong Developer Tools để xem các request lấy HTML có trả về nội dung kỳ vọng hay bị chặn (Block/CORS/Cloudflare).
- Nếu Request trả về nội dung đúng (Status 200) nhưng thông tin hiển thị lên trang web lại sai/trống, bạn hãy dùng `console.log` hiển thị các biến lưu kết quả parse (`cheerio`) trước khi `return` để kiểm tra độ chính xác của Selectors CSS mà bạn cung cấp. Quá trình này giúp phát hiện trường hợp phía website đã thay đổi giao diện làm bộ lọc cũ không hoạt động.
- Một server local sẽ chạy (cổng 3000). Copy địa chỉ IP LAN của máy tính.
- Mở ứng dụng LNReader trên điện thoại (đảm bảo điện thoại và máy tính dùng chung mạng Wifi).
- Vào **Cài đặt -> Repositories**, thêm URL `http://<IP-của-máy-tính>:3000/.dist/plugins.min.json` và cập nhật.
- Gỡ lỗi: Cài đặt và sử dụng plugin của bạn trên app thực. Nó cho phép test chính xác nhất hành vi của Custom JS/CSS trong Reader, tuy nhiên không có DevTools (console) để xem log nếu không dùng debug Application

## Một số lưu ý khi sử dụng plugin tương thích với [LNReader-Extended](https://github.com/Yuneko-dev/lnreader-extended)

Expand Down Expand Up @@ -128,4 +154,4 @@ window.reader.post({ type: 'refetch' });

- Nếu vẫn không được, có thể render Captcha trực tiếp trong màn hình Reader. Tuy nhiên, vì hàm `parseChapter` đã được chuẩn hóa, nên script và các thẻ khác có thể không chạy. Sử dụng customJS để xử lý hành vi.

- Trong Reader, URL (location) mặc định sử dụng sẽ là URL site của plugin (Không phải URL của Chapter)
- Trong Reader, URL (location) mặc định sử dụng sẽ là URL site của plugin (Không phải URL của Chapter). Trong Playground, nó là localhost URL.
231 changes: 231 additions & 0 deletions electron/browser/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Browser Tab</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0,0" rel="stylesheet" />

<style>
:root {
--md-sys-color-surface: #fdfcff;
--md-sys-color-surface-container: #f0f0f4;
--md-sys-color-surface-container-high: #eaeaef;
--md-sys-color-on-surface: #1a1c1e;
--md-sys-color-on-surface-variant: #43474e;
--md-sys-color-outline-variant: #c4c6d0;
--md-sys-color-primary: #0061a4;
--md-sys-state-hover: rgba(26, 28, 30, 0.08);
--md-sys-state-pressed: rgba(26, 28, 30, 0.12);
}

@media (prefers-color-scheme: dark) {
:root {
--md-sys-color-surface: #1a1c1e;
--md-sys-color-surface-container: #1e1f22;
--md-sys-color-surface-container-high: #2b2930;
--md-sys-color-on-surface: #e2e2e6;
--md-sys-color-on-surface-variant: #c4c6d0;
--md-sys-color-outline-variant: #43474e;
--md-sys-color-primary: #9ecaff;
--md-sys-state-hover: rgba(226, 226, 230, 0.08);
--md-sys-state-pressed: rgba(226, 226, 230, 0.12);
}
}

* { margin: 0; padding: 0; box-sizing: border-box; }

body {
display: flex;
flex-direction: column;
height: 100vh;
font-family: 'Roboto', -apple-system, sans-serif;
background: var(--md-sys-color-surface);
color: var(--md-sys-color-on-surface);
overflow: hidden;
}

#toolbar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--md-sys-color-surface-container);
-webkit-app-region: drag;
height: 56px;
}

.icon-button, #url-container { -webkit-app-region: no-drag; }

.icon-button {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--md-sys-color-on-surface-variant);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.2s ease;
}

.icon-button:hover {
background-color: var(--md-sys-state-hover);
color: var(--md-sys-color-on-surface);
}

.icon-button:active {
background-color: var(--md-sys-state-pressed);
}

.material-symbols-rounded {
font-size: 20px;
}

#url-container {
flex: 1;
display: flex;
align-items: center;
background: var(--md-sys-color-surface-container-high);
border-radius: 24px;
height: 40px;
padding: 0 16px;
gap: 8px;
margin-left: 4px;
transition: background-color 0.2s, box-shadow 0.2s;
}

#url-container:focus-within {
background: var(--md-sys-color-surface);
box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24);
}

#url-container .material-symbols-rounded {
color: var(--md-sys-color-on-surface-variant);
font-size: 18px;
}

#url {
flex: 1;
border: none;
background: transparent;
color: var(--md-sys-color-on-surface);
font-size: 14px;
font-family: inherit;
outline: none;
height: 100%;
}

#url::placeholder {
color: var(--md-sys-color-on-surface-variant);
opacity: 0.8;
}

webview {
flex: 1;
border: none;
background: var(--md-sys-color-surface);
}
</style>
</head>
<body>
<div id="toolbar">
<button class="icon-button" id="back" title="Back">
<span class="material-symbols-rounded">arrow_back</span>
</button>
<button class="icon-button" id="forward" title="Forward">
<span class="material-symbols-rounded">arrow_forward</span>
</button>
<button class="icon-button" id="refresh" title="Refresh">
<span class="material-symbols-rounded">refresh</span>
</button>

<div id="url-container">
<span class="material-symbols-rounded" id="security-icon">public</span>
<input id="url" type="text" placeholder="Search or type a URL" spellcheck="false" autocomplete="off" />
</div>

<button class="icon-button" id="devtools" title="Open WebView DevTools">
<span class="material-symbols-rounded">bug_report</span>
</button>
</div>

<webview id="browser" src="about:blank" partition="persist:lnreader_plugins" autosize="on"></webview>

<script>
const webview = document.getElementById('browser');
const urlInput = document.getElementById('url');
const securityIcon = document.getElementById('security-icon');

document.getElementById('back').onclick = () => { if (webview.canGoBack()) webview.goBack(); };
document.getElementById('forward').onclick = () => { if (webview.canGoForward()) webview.goForward(); };
document.getElementById('devtools').onclick = () => webview.openDevTools();

let isLoading = false;
const refreshBtn = document.getElementById('refresh');
const refreshIcon = refreshBtn.querySelector('.material-symbols-rounded');

refreshBtn.onclick = () => {
if (isLoading) webview.stop();
else webview.reload();
};

function navigate() {
let url = urlInput.value.trim();
if (!url) return;
if (!url.startsWith('http://') && !url.startsWith('https://')) url = 'https://' + url;
webview.loadURL(url);
}

urlInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
navigate();
urlInput.blur();
}
});

urlInput.addEventListener('focus', () => urlInput.select());

webview.addEventListener('did-start-loading', () => {
isLoading = true;
refreshIcon.textContent = 'close';
});

webview.addEventListener('did-stop-loading', () => {
isLoading = false;
refreshIcon.textContent = 'refresh';
});

webview.addEventListener('did-navigate', (e) => {
urlInput.value = e.url;
document.title = 'Browser - ' + e.url;
updateSecurityIcon(e.url);
});

webview.addEventListener('did-navigate-in-page', (e) => {
if (e.isMainFrame) {
urlInput.value = e.url;
updateSecurityIcon(e.url);
}
});

webview.addEventListener('page-title-updated', (e) => {
document.title = e.title;
});

function updateSecurityIcon(url) {
if (url.startsWith('https://')) {
securityIcon.textContent = 'lock';
} else if (url.startsWith('http://')) {
securityIcon.textContent = 'info';
} else {
securityIcon.textContent = 'public';
}
}
</script>
</body>
</html>
15 changes: 15 additions & 0 deletions electron/eslint.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';

export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.ts'],
rules: {
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'warn',
},
},
{ ignores: ['dist/**', 'dist-electron/**', 'node_modules/**'] },
);
26 changes: 26 additions & 0 deletions electron/lib/cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
type Cookie = {
name: string;
value: string;
path?: string;
domain?: string;
expires?: string;
secure?: boolean;
httpOnly?: boolean;
};
type Cookies = Record<string, Cookie>;

export function set(url: string, cookie: Cookie) {
return window.electronAPI!.invoke('cookie:set', url, cookie);
}
export function get(url: string): Promise<Cookies> {
return window.electronAPI!.invoke('cookie:get', url);
}
export function setFromResponse(url: string, cookie: string) {
return window.electronAPI!.invoke('cookie:set-from-response', url, cookie);
}
export function flush() {
return window.electronAPI!.invoke('cookie:flush');
}
export function removeSessionCookies() {
return window.electronAPI!.invoke('cookie:remove-session');
}
9 changes: 9 additions & 0 deletions electron/lib/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
interface ElectronAPI {
invoke(channel: string, ...args: unknown[]): Promise<any>;
}
declare global {
interface Window {
electronAPI?: ElectronAPI;
}
}
export {};
Loading
Loading