From 10bfb129946260af81bc1cb45a086ac94dcc6187 Mon Sep 17 00:00:00 2001 From: Shinren Pan Date: Mon, 4 May 2026 16:51:25 +0800 Subject: [PATCH] refactor with claude --- .swiftformat | 308 ++---------------- AI_CONTEXT.md | 36 -- CHANGELOG.md | 26 -- CLAUDE.md | 84 +++++ README.md | 62 ++-- WebParser/Sources/WebParser/WebParser.swift | 124 ++++--- .../Sources/WebParser/WebParserConfig.swift | 91 ++++-- .../Sources/WebParser/WebParserEngine.swift | 268 ++++++++++----- .../Sources/WebParser/WebParserMapper.swift | 78 +++-- .../Sources/WebParser/WebParserSession.swift | 75 ++++- .../Tests/WebParserTests/WebParserTests.swift | 244 ++++++++++---- .../WebParserDemo.xcodeproj/project.pbxproj | 6 +- .../WebParserDemo/App/AppDelegate.swift | 28 ++ .../WebParserDemo/App/SceneDelegate.swift | 59 ++++ .../ComicList/ComicListHostController.swift | 29 ++ .../ComicList/ComicListView.swift | 135 ++++++++ .../ComicList/ComicListViewModel+Models.swift | 42 +++ .../ComicList/ComicListViewModel.swift | 188 +++++++++++ .../WebParserDemo/ComicListView.swift | 96 ------ .../WebParserDemo/ComicListViewModel.swift | 149 --------- WebParserDemo/WebParserDemo/ContentView.swift | 30 -- WebParserDemo/WebParserDemo/Info.plist | 15 + .../TitleTest/TitleTestHostController.swift | 29 ++ .../TitleTest/TitleTestView.swift | 106 ++++++ .../TitleTest/TitleTestViewModel+Models.swift | 25 ++ .../TitleTest/TitleTestViewModel.swift | 119 +++++++ .../WebParserDemo/TitleTestView.swift | 111 ------- .../WebParserDemo/WebParserDemoApp.swift | 23 -- 28 files changed, 1530 insertions(+), 1056 deletions(-) delete mode 100644 AI_CONTEXT.md delete mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 WebParserDemo/WebParserDemo/App/AppDelegate.swift create mode 100644 WebParserDemo/WebParserDemo/App/SceneDelegate.swift create mode 100644 WebParserDemo/WebParserDemo/ComicList/ComicListHostController.swift create mode 100644 WebParserDemo/WebParserDemo/ComicList/ComicListView.swift create mode 100644 WebParserDemo/WebParserDemo/ComicList/ComicListViewModel+Models.swift create mode 100644 WebParserDemo/WebParserDemo/ComicList/ComicListViewModel.swift delete mode 100644 WebParserDemo/WebParserDemo/ComicListView.swift delete mode 100644 WebParserDemo/WebParserDemo/ComicListViewModel.swift delete mode 100644 WebParserDemo/WebParserDemo/ContentView.swift create mode 100644 WebParserDemo/WebParserDemo/TitleTest/TitleTestHostController.swift create mode 100644 WebParserDemo/WebParserDemo/TitleTest/TitleTestView.swift create mode 100644 WebParserDemo/WebParserDemo/TitleTest/TitleTestViewModel+Models.swift create mode 100644 WebParserDemo/WebParserDemo/TitleTest/TitleTestViewModel.swift delete mode 100644 WebParserDemo/WebParserDemo/TitleTestView.swift delete mode 100644 WebParserDemo/WebParserDemo/WebParserDemoApp.swift diff --git a/.swiftformat b/.swiftformat index de6b9d8..3d59252 100644 --- a/.swiftformat +++ b/.swiftformat @@ -1,376 +1,116 @@ -# --- 基本格式與語法 --- - -# 縮寫詞清單:強制這些詞彙全大寫 (例如變數名稱 id -> ID, url -> URL) --acronyms ID,URL,UUID - -# 括號風格:不使用 Allman 風格 (即花括號不需獨立換行,保持在同一行) --allman false - -# 閉包參數:將匿名參數 ($0) 轉換為命名參數 (例如: map { $0 } -> map { item in item }) --anonymous-for-each convert - -# 圖片/顏色字面量:保留其視覺寬度設定 --asset-literals visual-width - -# Async 捕獲:在閉包捕獲列表中明確標記 async ---async-capturing - -# 標記 (MARK) 前的空行處理 ---before-marks - -# 二進位數字分組:每 4 或 8 位加底線 (例如: 0b1010_1111) +--async-capturing +--before-marks --binary-grouping 4,8 - -# 呼叫處的括號風格:使用預設值 --call-site-paren default - -# 分類標記格式 (%c 會被替換為分類名稱) --category-mark "MARK: %c" - -# Class 排序閾值:0 表示總是對 Class 內成員進行排序 --class-threshold 0 - -# 右括號位置:平衡對齊 (與對應的左括號所在行對齊或對稱) --closing-paren balanced - -# 閉包回傳 Void:移除多餘的 `-> Void` --closure-void remove - -# 複雜屬性 (@Attribute):保留原本的換行格式,不強制變更 --complex-attributes preserve - -# 計算屬性的屬性 (@) 標記:保留原樣 --computed-var-attributes preserve - -# 條件賦值 (如 if let x = y):放在屬性之後 --conditional-assignment after-property - -# Git 衝突標記:如果檔案含有衝突標記 (<<<<<<<),則拒絕格式化 (避免損壞檔案) --conflict-markers reject - -# 日期格式化:使用系統預設 --date-format system - -# 十進位數字分組:每 3 或 6 位加底線 (例如: 1_000_000) --decimal-grouping 3,6 - -# 文檔註釋:強制放在宣告之前 (而非修飾符之後) --doc-comments before-declarations - -# Else 關鍵字位置:放在下一行 (Stroustrup 風格: } \n else { ) --else-position next-line - -# 空花括號:內部不加空格 (例如: {}) --empty-braces no-space - -# Enum 命名空間:總是使用 enum 作為命名空間 (而非 struct) --enum-namespaces always - -# Enum 排序閾值:0 表示總是對 case 排序 --enum-threshold 0 - -# Equatable 巨集:不自動添加 --equatable-macro none - -# 指數符號大小寫:使用小寫 (e) --exponent-case lowercase - -# 指數部分分組:不處理 --exponent-grouping disabled - -# Extension 存取權限控制 (ACL):將 public/private 放在 extension 關鍵字前 --extension-acl on-extension - -# Extension 標記格式 (%t: 類型, %c: 分類) --extension-mark "MARK: - %t + %c" - -# Extension 排序閾值:0 表示總是排序 --extension-threshold 0 - -# File 巨集:使用 #file 而非 #filePath --file-macro "#file" - -# 小數部分分組:不處理 --fraction-grouping disabled - -# 片段模式:關閉 (處理整個檔案) --fragment false - -# 函式屬性 (@) 標記:保留原樣 --func-attributes preserve - -# 泛型類型參數格式化 ---generic-types - -# 空行分組:保留宣告之間的分組空行 +--generic-types --group-blank-lines true - -# 分組 Extension 的標記格式 --grouped-extension "MARK: %c" - -# Guard Else:自動判斷 (短的單行,長的換行) --guard-else auto - -# 檔案檔頭 (Header):忽略 (不自動新增或移除版權宣告) --header ignore - -# 十六進位分組:每 4 或 8 位加底線 --hex-grouping 4,8 - -# 十六進位字面量大小寫:使用大寫 (例如: 0xFF) --hex-literal-case uppercase - -# #if/#endif 縮排:進行縮排 --ifdef indent - -# Import 語句排序:按字母順序 --import-grouping alpha - -# 縮排寬度:2 個空格 --indent 2 - -# Case 語句縮排:不額外縮排 (case 與 switch 對齊) --indent-case false - -# 多行字串縮排:不調整 --indent-strings false - -# 推斷類型:總是使用推斷類型 (移除顯式類型宣告,例如 let x: Int = 0 -> let x = 0) --inferred-types always - -# 構造器 (Init) 返回 nil:不強制處理 --init-coder-nil false - -# 語言模式:自動偵測 --language-mode 0 - -# 生命週期函式 (如 viewDidLoad) 排序 ---lifecycle - -# MARK 標記後是否保留空行 +--lifecycle --line-after-marks true - -# 多個 Guard 語句之間是否保留空行 --line-between-guards true - -# 換行符號:強制使用 LF (Unix 風格 \n) --linebreaks lf - -# 是否標記 Extension 分類 --mark-categories true - -# 是否標記 Extension --mark-extensions always - -# 是否標記特定類型 --mark-types always - -# 單行最大字元寬度:100 字元 --max-width 100 - -# 修飾符 (public, static 等) 排序 ---modifier-order - -# Never 返回類型:不強制使用 trailing 格式 ---never-trailing - -# Optional 初始化:移除顯式的 nil (例如: var x: Int? = nil -> var x: Int?) +--modifier-order +--never-trailing --nil-init remove - -# 運算子周圍空格:不強制移除 ---no-space-operators - -# 運算子換行:不強制禁止 ---no-wrap-operators - -# 非複雜屬性:預設處理 ---non-complex-attributes - -# 八進位分組:每 4 或 8 位 +--no-space-operators +--no-wrap-operators +--non-complex-attributes --octal-grouping 4,8 - -# 運算子函式:保留空格 --operator-func spaced - -# 組織模式:根據可見性 (public/private) 排序 --organization-mode visibility - -# 類型內部成員排序順序 (Actor, Class, Enum, Struct) --organize-types actor,class,enum,struct - -# Pattern Let:提升 let 到頂層 (例如 if case .foo(let x) -> if case let .foo(x)) --pattern-let hoist - -# 保留縮寫詞的原樣 ---preserve-acronyms - -# 保留宣告的原樣 (不隨意刪除) ---preserve-decls - -# 保留特定類型的屬性名稱 (此處設定為 Package) +--preserve-acronyms +--preserve-decls --preserved-property-types Package - -# 屬性類型推斷:僅推斷區域變數 ---property-types infer-locals-only - -# 區間運算子空格:兩側加空格 (例如 0 ..< 10) +#--property-types explicit --ranges spaced - -# Self 關鍵字:移除不必要的 self --self remove - -# 必須使用 self 的場景設定 ---self-required - -# 分號處理:允許行內分號 +--self-required --semicolons inline - -# 簡寫 Optional:除了屬性外,使用 ? 代替 Optional --short-optionals except-properties - -# 單行 For-Each:忽略 (不強制展開) --single-line-for-each ignore - -# 智慧 Tab:啟用 (通常指對齊用空格,縮排用 Tab,但上方已設 tab-width 2,此處可能指對齊邏輯) --smart-tabs enabled - -# some/any 關鍵字:啟用 Swift 5.7+ 語法支援 --some-any true - -# SwiftUI 屬性排序:不排序 (保留開發者撰寫順序) --sort-swiftui-properties none - -# 模式匹配排序 ---sorted-patterns - -# 儲存屬性 (@) 標記:保留原樣 +--sorted-patterns --stored-var-attributes preserve - -# 未使用的參數:總是移除 (使用 _) --strip-unused-args always - -# Struct 排序閾值:0 表示總是排序 --struct-threshold 0 - -# Swift 版本:指定目標為 6.2 (允許最新語法) ---swift-version 6.2 - -# Tab 寬度:2 個空格 (配合 --indent 2) +--swift-version 6.3 --tab-width 2 - -# 錯誤捕獲:格式化 throw capturing ---throw-capturing - -# 時區設定:使用系統時區 (影響日期格式化) +--throw-capturing --timezone system - -# 尾隨閉包 (Trailing Closures):自動轉換 ---trailing-closures - -# 尾隨逗號 (Trailing Commas):總是添加 (陣列/字典最後一項加逗號,方便版控 diff) +--trailing-closures --trailing-commas always - -# 移除行尾空白:總是移除 --trim-whitespace always - -# 類型屬性 (@) 標記:保留原樣 --type-attributes preserve - -# 類型內部的空行:移除 (緊湊風格) --type-blank-lines remove - -# 類型分隔符 (:):冒號後加空格 --type-delimiter space-after - -# 類型標記格式 --type-mark "MARK: - %t" - -# 是否添加類型標記 ---type-marks - -# 類型排序順序 ---type-order - -# URL 巨集:不使用 +--type-marks +--type-order --url-macro none - -# 可見性標記 (MARK) ---visibility-marks - -# 可見性排序順序 ---visibility-order - -# Void 類型寫法:使用 Void (而非 ()) +--visibility-marks +--visibility-order --void-type void - -# --- 換行 (Wrapping) 設定 --- -# 下列設定多為 "preserve",表示保留你手動換行的格式,SwiftFormat 不會強制合併或拆分 - -# 函式參數換行:保留 --wrap-arguments preserve -# 集合 (Array/Dict) 換行:保留 --wrap-collections preserve -# 條件式 (if/guard) 換行:保留 --wrap-conditions preserve -# 效果 (throws/async) 換行:保留 --wrap-effects preserve -# Enum case 換行:總是換行 --wrap-enum-cases always -# 參數定義換行:預設 --wrap-parameters default -# 返回類型 (-> Type) 換行:保留 --wrap-return-type preserve -# 字串插值換行:預設 --wrap-string-interpolation default -# 三元運算子 ( ? : ) 換行:預設 --wrap-ternary default -# Type Alias 換行:保留 --wrap-type-aliases preserve - -# Xcode 縮排:停用 (完全由 SwiftFormat 接管) --xcode-indentation disabled - -# XCTest 符號處理 ---xctest-symbols - -# Yoda 條件式 (if 5 == count):總是交換為自然語序 (if count == 5) +--xctest-symbols --yoda-swap always - -# --- 規則啟用與停用清單 --- - -# 停用的規則 (Disable List) -# docCommentsBeforeModifiers: 文檔註釋不強制移到修飾符前 (與上方的 doc-comments before-declarations 配合調整) -# fileHeader: 不處理檔案頭部 -# numberFormatting: 不自動格式化數字 -# redundantLet: 不移除多餘的 let -# redundantRawValues: 不移除 Enum 多餘的原始值 -# redundantSelf: 不移除多餘的 self (注意:上方有 --self remove,這裡停用規則可能會產生衝突,以 flag 為主或需檢查版本行為) -# strongifiedSelf: 不將 weak self 轉為強引用 -# swiftTestingTestCaseNames: 不強制 Swift Testing 測試案例命名 -# trailingClosures: (重複設定,上方已有參數) 停用規則形式的尾隨閉包處理 -# unusedArguments: (重複設定) 停用未使用的參數移除規則 -# wrap/wrapArguments...: 停用自動換行相關規則 (完全手動控制) ---disable docCommentsBeforeModifiers,fileHeader,fileMacro,numberFormatting,redundantLet,redundantRawValues,redundantSelf,strongifiedSelf,swiftTestingTestCaseNames,trailingClosures,unusedArguments,wrap,wrapArguments,wrapAttributes,wrapSingleLineComments - -# 啟用的規則 (Enable List) -# blankLineAfterSwitchCase: Switch case 結束後加空行 -# blankLinesAfterGuardStatements: Guard 語句後加空行 -# blankLinesBetweenImports: Import 語句間加空行 -# blockComments: 允許區塊註釋 -# emptyExtensions: 保留空的 Extension -# environmentEntry: 格式化 SwiftUI Environment 注入 -# isEmpty: 使用 .isEmpty 替換 count == 0 -# noExplicitOwnership: 移除顯式的擁有權修飾符 (如預設的 strong) -# noGuardInTests: 測試中禁止使用 guard -# privateStateVariables: SwiftUI @State 變數設為 private -# propertyTypes: 格式化屬性類型 -# redundantMemberwiseInit: 移除多餘的成員初始化函式 -# redundantProperty: 移除多餘的屬性 -# singlePropertyPerLine: 每行只定義一個屬性 -# sortSwitchCases: Switch case 排序 -# urlMacro: 使用 URL 巨集 -# wrapConditionalBodies: 條件式內容換行 -# wrapEnumCases: Enum case 換行 -# wrapMultilineConditionalAssignment: 多行條件賦值換行 -# wrapMultilineFunctionChains: 多行函式鏈式調用換行 ---enable blankLineAfterSwitchCase,blankLinesAfterGuardStatements,blankLinesBetweenImports,blockComments,emptyExtensions,environmentEntry,isEmpty,noExplicitOwnership,noGuardInTests,privateStateVariables,propertyTypes,redundantMemberwiseInit,redundantProperty,singlePropertyPerLine,sortSwitchCases,urlMacro,wrapConditionalBodies,wrapEnumCases,wrapMultilineConditionalAssignment,wrapMultilineFunctionChains +--disable docCommentsBeforeModifiers,fileHeader,fileMacro,numberFormatting,redundantLet,redundantRawValues,redundantSelf,strongifiedSelf,swiftTestingTestCaseNames,trailingClosures,unusedArguments,wrap,wrapArguments,wrapAttributes,wrapSingleLineComments,redundantViewBuilder,redundantSendable,propertyTypes +--enable blankLineAfterSwitchCase,blankLinesAfterGuardStatements,blankLinesBetweenImports,blockComments,emptyExtensions,environmentEntry,isEmpty,noExplicitOwnership,noGuardInTests,privateStateVariables,redundantMemberwiseInit,redundantProperty,singlePropertyPerLine,sortSwitchCases,urlMacro,wrapConditionalBodies,wrapEnumCases,wrapMultilineConditionalAssignment,wrapMultilineFunctionChains diff --git a/AI_CONTEXT.md b/AI_CONTEXT.md deleted file mode 100644 index 7fa65a6..0000000 --- a/AI_CONTEXT.md +++ /dev/null @@ -1,36 +0,0 @@ -# 🤖 WebParser Project Context for AI - -## 📌 專案定位 -- **名稱**: WebParser -- **作者**: Joe Pan -- **目標**: 基於 WebKit 離屏渲染的現代 Swift 網頁解析框架, 專門處理動態 DOM 與 JavaScript 渲染. -- **核心架構**: Swift 6 Concurrency, Async/Await, 狀態機驅動 (State Machine), 模組化 Mapper. - -## 🛠️ 技術棧 (2026/02) -- **語言**: Swift 6.2 (啟動 Strict Concurrency 檢查) -- **最低平台**: iOS 17.0+ -- **測試框架**: Swift Testing (非舊版 XCTest) -- **UI 框架**: SwiftUI (搭配 Observation 框架) -- **格式規範**: 遵循根目錄 `.swiftformat` (縮排 2 空格, 縮寫全大寫, 結尾 Trailing Commas). - - - -## 🧠 關鍵決策紀錄 (Memory) -1. **Actor Isolation**: `WebParserEngine` 嚴格隔離在 `@MainActor`, 因為 `WKWebView` 必須在主執行緒操作. -2. **Data-Centric Transfer**: 為了符合 `Sendable` 規範, `Engine` 抓取結果後統一序列化為 `Data` 再傳遞給 `Mapper`, 避免 Data Race. -3. **JS Handling**: 使用 `#"""` (Raw Strings) 處理 JavaScript, 保持腳本的可讀性. -4. **State Machine**: 透過 `WebParserState` 枚舉管理生命週期,取代分散的布林值標記. - -## 🤝 協作規範 -- **DocC 標註**: 所有 Public API 必須附帶中文 DocC 註釋, 標點符號使用英文半形包含逗點, 句點, 以及其他中文全形. -- **命名哲學**: 縮寫詞如 `URL`, `ID`, `JS`, `JSON` 必須保持全大寫. -- **程式碼組織**: 使用 `MARK: -` 進行區塊分割, 內部成員按可見性排序. - -## 🚀 待辦事項 (Backlog) -- [x] 基礎架構與 Swift 6 併發優化. -- [x] 中文 DocC 文檔全面覆蓋. -- [ ] 實作並行爬取連接池 (Connection Pool). -- [ ] 增加對代理伺服器 (Proxy) 的支援. - ---- -*Last Updated: 2026-02-02 by Joe Pan & Gemini 1.5 Flash (AI)* diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index b569153..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,26 +0,0 @@ -# Changelog - -本專案的所有重要變動都將記錄於此. 格式參考自 [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 並遵循 [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [1.0.0] - 2026-02-02 - -這是 **WebParser** 的第一個正式穩定版本. - -### ✨ 新增 (Added) -- **Core Engine**: 基於 `WKWebView` 的異步解析引擎, 支援 Swift 6 Concurrency. -- **Generic Mappers**: 實作 `WebParserJSJSONMapper` 與 `WebParserRegexMapper` 協定. -- **Progress Tracking**: 加入 `WebParserProgressDelegate` 以監控網頁載入與狀態機 (State Machine) 變化. -- **SwiftPM Support**: 完整的 Swift Package Manager 支持, 包含 `Package.swift` 配置. -- **Unit Testing**: 全面採用 **Swift Testing** 框架實作單元測試與整合測試. - -### 🛠️ 優化 (Changed) -- **Raw String Integration**: 全面改用 Swift `#"""` 語法處理內部 JavaScript 腳本, 消除轉義字元維護痛點. -- **Thread Safety**: 強制將所有 WebKit 操作隔離至 `@MainActor`, 確保並發環境下的 UI 執行緒安全. -- **Media Optimization**: 預設阻擋多媒體資源載入以提升離屏渲染效能. - -### 🤖 AI Collaboration -- 由 **Joe Pan (Human)** 與 **Gemini 1.5 Flash (AI)** 深度協作完成整體架構設計. -- 共同攻克離屏渲染中 `withCheckedThrowingContinuation` 的生命週期管理與超時控制邏輯. - ---- -*Generated by WebParser Team (Joe Pan & Gemini 1.5 Flash (AI))* diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9f02552 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,84 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## 常用指令 + +```bash +# 執行所有測試(必須先進入 WebParser/ 子目錄) +cd WebParser && swift test --parallel + +# 執行單一測試 +cd WebParser && swift test --filter "testJSONMapping" + +# 提交前格式化程式碼 +swiftformat . +``` + +CI 從 `WebParser/` 子目錄執行 `swift test --parallel`(見 `.github/workflows/test.yml`),本地執行時務必先 `cd WebParser`。 + +## 架構 + +專案包含兩個頂層目錄: + +- **`WebParser/`** — Swift Package(函式庫本體),所有 Public API 位於此處。 +- **`WebParserDemo/`** — Xcode App 專案,示範如何使用函式庫。 + +### 函式庫內部結構(`WebParser/Sources/WebParser/`) + +解析流程由四個組件依序串接: + +``` +WebParser(入口) + → WebParserSession(Cookie / 資料存儲單例) + → WebParserEngine(WKWebView 離屏渲染引擎) + → WebParserMapper(資料轉換器) +``` + +**`WebParser`**(`@MainActor class`)— 協調完整生命週期:Cookie 同步 → Engine 執行 → 重試迴圈 → Mapper 映射。呼叫端提供 `WebParserConfig` 與任意 `WebParserMapper`,回傳類型為泛型 `M.T`。 + +**`WebParserEngine`**(`@MainActor class`)— 將 `WKWebView` 包裝在 `CheckedContinuation` 中。頁面載入完成後進入輪詢迴圈,持續呼叫 `evaluateJavaScript` 直到結果非空或逾時。結果透過 `JSONSerialization` 序列化為 `Data` 再回傳,這是維持 `Sendable` 合規的關鍵橋接點。 + +**`WebParserMapper`**(protocol)— 負責 `Data → T` 的轉換,內建兩個實作: +- `WebParserJSJSONMapper` — 使用 `JSONDecoder` 解碼。適用於 `executionJS` 回傳 JS 物件或陣列的情境。 +- `WebParserRegexMapper` — 將轉換邏輯委派給呼叫端提供的 `(Data) throws -> T` 閉包,回傳型別不限 `Codable`。適用於 `executionJS` 回傳純文字(如 `document.title` 或完整 HTML)的情境。 + +**`WebParserSession`**(`@MainActor` 單例)— 持有共用的 `WKWebsiteDataStore`,確保 Cookie 與快取在不同解析任務之間正確共享。 + +**`WebParserState`**(enum)— 狀態機,透過 `WebParserProgressDelegate` 發送:`started → loading(progress:) → executingJavaScript → completed | retrying | failed`。 + +### Demo App 結構(`WebParserDemo/WebParserDemo/`) + +Demo 採用 UIKit SceneDelegate 架構,目錄分為三組: + +- **`App/`** — `AppDelegate`(`@main`)、`SceneDelegate`(建立 `UITabBarController` + 兩個 `UINavigationController`)、`ViewModel` protocol 定義。 +- **`TitleTest/`** — 輸入 URL 抓取網頁標題的功能,使用 `WebParserRegexMapper`。 +- **`ComicList/`** — 解析漫畫更新列表的功能,使用 `WebParserJSJSONMapper` 搭配 `WebParserProgressDelegate` 顯示載入進度。 + +每個 Feature 遵循 `HostController → ViewModel → View` 三層架構,ViewModel 使用 `@Observable`。 + +### 關鍵設計決策 + +- 所有 WebKit 操作均標記 `@MainActor`,因為 `WKWebView` 必須在主執行緒操作。 +- Engine 回傳 `Data`(而非 `Any`),以滿足跨並發邊界的 `Sendable` 規範。 +- JavaScript 腳本應使用 Swift Raw String(`#"""..."""#`),避免跳脫字元問題。 +- `blockMedia = true`(預設值)透過 `mediaTypesRequiringUserActionForPlayback` 阻擋影片與音訊載入,建議維持開啟以提升效能。 + +## 程式碼風格 + +- **格式化工具**:每次提交前執行 `swiftformat .`,配置檔為根目錄 `.swiftformat`。 +- **縮排**:2 個空格。 +- **縮寫詞**:全大寫,例如 `URL`, `ID`, `JS`, `JSON`, `UUID`。 +- **尾隨逗號**:集合型別結尾總是加逗號。 +- **MARK 區塊**:使用 `MARK: -` 分隔程式碼區塊,成員依可見性排序。 +- **DocC 文件**:所有 Public API 必須附帶中文 DocC 註釋,標點符號使用英文半形(逗號 `,`、句點 `.`)。 +- **測試框架**:使用 Swift Testing(`@Test`、`#expect`),不使用 XCTest。 +- **Swift 版本**:6.2,啟用 Strict Concurrency 檢查。 + +## Skill Directory Map + +> 所有路徑基於 `~/.claude/skills/` +> 載入目錄即載入其下所有檔案(SKILL.md + references/) + +### 所有任務必須載入 +- `swift-concurrency/` \ No newline at end of file diff --git a/README.md b/README.md index 856ca2a..cbd7ec6 100644 --- a/README.md +++ b/README.md @@ -7,32 +7,17 @@ 一個基於 WebKit 離屏渲染的現代 Swift 網頁解析框架. 專為處理需要 JavaScript 執行、智慧輪詢 (Polling) 以及動態 DOM 加載的複雜網頁而設計. -[Changelog](CHANGELOG.md) - ## ✨ 特色 - **Swift 6 原生支持**: 全面採用 `async/await` 架構, 並針對執行緒安全 (Concurrency Safety) 進行優化. - **動態網頁渲染**: 內建 `WKWebView` 引擎, 支持執行自定義 JavaScript, 輕鬆應對 SPA 或加密數據網站. - **類型安全映射 (Mapper)**: - - `WebParserJSJSONMapper`: 直接將 JS 回傳的 JSON 轉換為 Swift `Codable` 模型. - - `WebParserRegexMapper`: 利用自定義邏輯或正規表達式從原始數據中擷取內容. + - `WebParserJSJSONMapper`: 直接將 JS 回傳的 JSON 轉換為 Swift `Decodable` 模型. + - `WebParserRegexMapper`: 透過自訂閉包從原始數據中擷取任意型別的內容, 不限於 `Codable`. - **智慧進度監控**: 透過 `WebParserProgressDelegate` 即時追蹤網頁載入與解析狀態機變化. - **效能優化**: 支持阻擋多媒體資源 (Block Media), 顯著提升渲染速度並節省流量. - **開發友善**: 支持 Swift `#"""` 語法, 讓 JavaScript 腳本維護不再受轉義字元困擾. - - -## 🎨 代碼風格 (Code Style) - -本專案由 **Joe Pan** 發起並維護, 強制執行嚴格的格式化規範: - -- **縮排**: 使用 2 個空格. -- **命名規範**: 縮寫詞全大寫 (如 `URL`, `ID`). -- **Swift 6 兼容**: 針對 Swift 6.2 語法優化, 確保 Strict Concurrency 檢查全數通過. -- **尾隨逗號**: 集合型別結尾總是添加逗號, 優化 Git Diff 體驗. - -如果你提交 Pull Request, 請確保已執行過 `swiftformat .`. - ## 🛠️ 安裝方式 透過 **Swift Package Manager** 加入你的專案: @@ -45,14 +30,15 @@ dependencies: [ ## 🚀 快速上手 -`WebParser` 透過不同的 `Mapper` 來決定解析邏輯, 你可以根據網頁回傳的數據類型靈活選擇。 +`WebParser` 透過不同的 `Mapper` 來決定解析邏輯, 你可以根據網頁回傳的數據類型靈活選擇. ### 1. 使用 `WebParserJSJSONMapper` (推薦) -當你的 `executionJS` 回傳的是 JavaScript 對象或陣列時, 這是最高效的方式。引擎會自動處理序列化, 你只需定義 `Codable` 模型。 + +當你的 `executionJS` 回傳的是 JavaScript 對象或陣列時, 這是最高效的方式. 引擎會自動處理序列化, 你只需定義 `Decodable` 模型. ```swift // 定義模型 -struct Comic: Codable { +struct Comic: Decodable { let title: String let updateDate: String } @@ -66,10 +52,10 @@ print("抓取到 \(results.count) 本漫畫") ``` ### 2. 使用 `WebParserRegexMapper` -當你只需要從網頁原始碼 (HTML) 中利用正規表達式或字串處理來擷取特定文字時使用。 + +當你需要從回傳的原始字串中利用自定義邏輯擷取特定內容時使用. ```swift -// 假設 executionJS 回傳的是整個網頁的 document.title 或特定 HTML 區塊 let config = WebParserConfig( url: url, executionJS: "document.title" @@ -89,34 +75,34 @@ print("網頁標題是: \(webTitle)") ## 🧩 進階用法 ### 監聽解析狀態 + 讓你的 ViewModel 遵守 `WebParserProgressDelegate`: + ```swift func webParser(_ parser: WebParser, didUpdateState state: WebParserState) { - if case .loading(let progress) = state { - self.uiProgress = progress // 更新 SwiftUI 進度條 - } + if case let .loading(progress) = state { + self.uiProgress = progress + } } ``` +## 🎨 程式碼風格 + +- **縮排**: 使用 2 個空格. +- **命名規範**: 縮寫詞全大寫 (如 `URL`, `ID`, `JS`, `JSON`). +- **Swift 6 兼容**: 針對 Swift 6.2 語法優化, 確保 Strict Concurrency 檢查全數通過. +- **尾隨逗號**: 集合型別結尾總是添加逗號, 優化 Git Diff 體驗. + +如果你提交 Pull Request, 請確保已執行過 `swiftformat .`. + ## 🧪 測試 本專案全面導入 **Swift Testing** 框架, 確保核心邏輯與 WebKit 渲染的穩定性. ```bash -# 在終端機執行測試 -swift test +cd WebParser && swift test --parallel ``` -## 🤖 協作致謝 (Co-creation) - -本專案由 **Joe Pan (開發者)** 與 **Gemini 1.5 Flash (AI)** 深度協作完成. - -在開發過程中, 我們共同解決了以下挑戰: -- **執行緒隔離**: 處理 `WKWebView` 在 Swift 6 嚴格檢查下的隔離問題. -- **抽象化設計**: 實作了 `WebParserMapper` 協定, 讓解析邏輯具備極佳的擴充性. - -> 本專案利用 **Gemini 1.5 Flash** 的長文本推理與 Swift 6 語法理解能力, 優化了框架的併發安全架構. -> 「這不只是一段程式碼, 更是人類創意與 AI 邏輯交織的成果.」 - --- + Released under the MIT License by Joe Pan. diff --git a/WebParser/Sources/WebParser/WebParser.swift b/WebParser/Sources/WebParser/WebParser.swift index 8c20a8c..fb76a83 100644 --- a/WebParser/Sources/WebParser/WebParser.swift +++ b/WebParser/Sources/WebParser/WebParser.swift @@ -9,17 +9,17 @@ import Foundation /// 描述 WebParser 在執行週期中的各個狀態. public enum WebParserState { - /// 解析流程已啟動. + /// 解析流程已啟動, 此狀態在整個解析週期內只會發出一次. case started - /// 網頁載入中. progress 為 0.0 到 1.0 之間的進度值. + /// 網頁載入中. `progress` 為 0.0 到 1.0 之間的進度值. case loading(progress: Double) /// 引擎正在執行指定的 JavaScript 腳本. case executingJavaScript /// 解析流程已順利完成. case completed - /// 發生可重試的錯誤. 記錄當前嘗試次數與錯誤原因. + /// 發生可重試的網路錯誤. `attempt` 為目前的重試次數, `error` 為錯誤原因. case retrying(attempt: Int, error: Error) - /// 流程失敗. 通常發生在用盡重試次數或遇到不可恢復的錯誤時. + /// 流程失敗. 發生在用盡所有重試次數, 遇到不可恢復的錯誤, 或任務被取消時. case failed(Error) } @@ -27,78 +27,120 @@ public enum WebParserState { public protocol WebParserProgressDelegate: AnyObject { /// 通知代理對象解析狀態已更新. /// - Parameters: - /// - parser: 發出通知的 WebParser 實例. + /// - parser: 發出通知的 ``WebParser`` 實例. /// - state: 當前的執行狀態. @MainActor func webParser(_ parser: WebParser, didUpdateState state: WebParserState) } /// WebParser 框架的核心入口類別. /// -/// `WebParser` 提供了一個高層級介面來處理網頁爬取任務. 它利用 WebKit 的離屏渲染技術. -/// 解決了傳統爬蟲難以處理動態渲染 (JavaScript) 或單頁應用 (SPA) 的痛點. +/// `WebParser` 提供高層級介面來執行網頁解析任務. 內部透過 WebKit 離屏渲染執行 JavaScript, +/// 能處理 SPA 動態渲染或需要 Cookie 登入的複雜網站. /// -/// ### 主要特性 -/// - **型別安全**: 搭配 ``WebParserMapper`` 確保輸出的資料符合模型定義. -/// - **韌性設計**: 內建自動重試機制. 可處理暫時性的網路波動. -/// - **現代化並發**: 完全相容 Swift 6 的 Concurrency 檢查與 `@MainActor` 隔離. +/// ### 基礎用法 +/// ```swift +/// let parser = WebParser() +/// let config = WebParserConfig(url: url, executionJS: "document.title") +/// let title = try await parser.parse( +/// with: config, +/// mapper: WebParserRegexMapper { data in +/// String(data: data, encoding: .utf8) ?? "" +/// } +/// ) +/// ``` +/// +/// ### 取消任務 +/// 此方法完整支援 Swift Structured Concurrency 的取消機制. +/// 對包裝此呼叫的 `Task` 呼叫 `.cancel()`, 解析會立即中斷並拋出 `CancellationError`. +/// ```swift +/// let task = Task { try await parser.parse(with: config, mapper: mapper) } +/// task.cancel() // 立即中斷, 釋放 WKWebView 資源 +/// ``` @MainActor -public class WebParser { +public final class WebParser { + // MARK: - Public + /// 接收狀態更新的代理對象. public weak var delegate: WebParserProgressDelegate? - /// 當前正在使用的解析配置. - var currentConfig: WebParserConfig? - /// 初始化 WebParser 實例. - /// - Parameter delegate: 可選的代理對象. 用於監聽執行狀態. + /// - Parameter delegate: 可選的代理對象, 用於監聽執行狀態. public init(delegate: WebParserProgressDelegate? = nil) { self.delegate = delegate } /// 根據指定的配置與對應器執行網頁解析. /// - /// 此方法協調整個爬取生命週期. 包含 Cookie 同步, 引擎執行, 重試邏輯以及資料映射. + /// 此方法協調整個解析生命週期:Cookie 同步, 引擎執行, 自動重試以及資料映射. + /// + /// 狀態轉換順序:`.started` → `.loading` → `.executingJavaScript` + /// → `.completed` 或 → `.retrying` (可重試錯誤) → `.failed` (最終失敗). /// /// - Parameters: - /// - config: 定義 URL, JavaScript 腳本與重試策略的 ``WebParserConfig`` 對象. - /// - mapper: 用於將原始資料轉換為特定類型的 ``WebParserMapper``. - /// - Returns: 經過映射處理後的結果 (類型為 `M.T`). - /// - Throws: 若重試後仍失敗則拋出 `URLError`. 或拋出對應器產生的映射錯誤. + /// - config: 定義目標 URL, JavaScript 腳本與重試策略的 ``WebParserConfig``. + /// - mapper: 負責將原始資料轉換為指定模型的 ``WebParserMapper``. + /// - Returns: 經 Mapper 處理後的結果, 類型為 `M.T`. + /// - Throws: 解析失敗時拋出 `URLError`, 映射失敗時拋出對應器產生的錯誤, + /// 任務取消時拋出 `CancellationError`. public func parse(with config: WebParserConfig, mapper: M) async throws -> M.T { - self.currentConfig = config - if config.shouldAutoInjectCookies { - await WebParserSession.shared.syncSystemCookies() + // 只同步與目標網域相關的 Cookie, 避免洩漏其他 domain 的敏感資訊 + await WebParserSession.shared.syncCookies(for: config.url.host(percentEncoded: false)) } - for attempt in 0 ... config.maxRetryCount { - do { - delegate?.webParser(self, didUpdateState: .started) - let engine = WebParserEngine(delegate: self.delegate, parser: self) + // .started 在整個解析週期只發出一次, 重試期間不重複發出 + notify(.started) - // 取得引擎回傳的原始 Data - let rawResult = try await engine.fetch(config) + var lastError: Error = URLError(.unknown) - // 執行資料映射 - let mappedResult = try mapper.map(result: rawResult) + for attempt in 0 ... config.maxRetryCount { + do { + // 每次嘗試建立獨立的 Engine 實例, 確保 WKWebView 狀態乾淨 + let engine = WebParserEngine(config: config) { [weak self] state in + self?.notify(state) + } - delegate?.webParser(self, didUpdateState: .completed) - return mappedResult + let rawData = try await engine.fetch() + let result = try mapper.map(result: rawData) + notify(.completed) + return result + } + catch is CancellationError { + // 任務取消不屬於應用層錯誤, 直接重新拋出, 不通知 delegate + throw CancellationError() } - catch let error as URLError where shouldRetry(for: error) && attempt < config.maxRetryCount { - delegate?.webParser(self, didUpdateState: .retrying(attempt: attempt + 1, error: error)) + catch let urlError as URLError where shouldRetry(urlError) { + // 暫時性網路錯誤: 記錄最後一次失敗, 若已是最後一次嘗試則跳出迴圈 + lastError = urlError + guard attempt < config.maxRetryCount else { + break + } + + notify(.retrying(attempt: attempt + 1, error: urlError)) try await Task.sleep(for: config.retryInterval) } catch { - delegate?.webParser(self, didUpdateState: .failed(error)) + // 其他不可恢復的錯誤, 通知代理後直接拋出 + notify(.failed(error)) throw error } } - throw URLError(.unknown) + + // 可重試錯誤用盡所有次數後到達此處 + notify(.failed(lastError)) + throw lastError + } + + // MARK: - Private + + private func notify(_ state: WebParserState) { + delegate?.webParser(self, didUpdateState: state) } - /// 根據錯誤代碼判斷是否應該啟動重試機制. - private func shouldRetry(for error: URLError) -> Bool { - [.timedOut, .networkConnectionLost, .notConnectedToInternet, .zeroByteResource].contains(error.code) + /// 判斷錯誤是否為值得重試的暫時性網路問題. + /// 邏輯錯誤, 伺服器錯誤或 JS 錯誤均不應重試. + private func shouldRetry(_ error: URLError) -> Bool { + [.timedOut, .networkConnectionLost, .notConnectedToInternet, .zeroByteResource] + .contains(error.code) } } diff --git a/WebParser/Sources/WebParser/WebParserConfig.swift b/WebParser/Sources/WebParser/WebParserConfig.swift index b30f156..ca9b465 100644 --- a/WebParser/Sources/WebParser/WebParserConfig.swift +++ b/WebParser/Sources/WebParser/WebParserConfig.swift @@ -10,21 +10,24 @@ import Foundation /// 定義模擬瀏覽器的行為標識 (User-Agent). /// -/// 不同的 User-Agent 會引發網站回傳不同的 HTML 結構或內容 (例如行動版與桌面版的差異). +/// 不同的 User-Agent 會讓網站回傳不同版面的 HTML 內容 (例如行動版與桌面版的差異). public enum WebParserUserAgent { - /// 模擬 iOS 裝置. 可指定 OS 版本號 (例如 "17.4"). + /// 模擬 iOS 裝置. 可選擇性指定 OS 版本 (例如 `"17.4"`), 留空使用預設值 `17_0`. case iOS(version: String? = nil) - /// 使用完全自定義的 User-Agent 字串. + /// 完全自訂的 User-Agent 字串. case custom(String) - /// 取得對應的 User-Agent 字串值. + /// 對應的 User-Agent 字串值. public var value: String { switch self { case let .iOS(version): - let os = version ?? "17_0" - return "Mozilla/5.0 (iPhone; CPU iPhone OS \(os.replacingOccurrences(of: ".", with: "_")) like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" + // 將版本號中的點號換為底線以符合 UA 字串規範 (例如 17.4 → 17_4) + let normalizedVersion = (version ?? "17_0").replacingOccurrences(of: ".", with: "_") + return + "Mozilla/5.0 (iPhone; CPU iPhone OS \(normalizedVersion) like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" - case let .custom(val): return val + case let .custom(value): + return value } } } @@ -32,54 +35,70 @@ public enum WebParserUserAgent { /// WebParser 的核心配置模型. /// /// ## 概述 -/// 此結構體集成了所有影響網頁渲染與資料擷取的參數. 透過調整此配置, 你可以模擬不同的裝置環境並優化解析效能. +/// 集成所有影響網頁渲染與資料擷取的參數. 透過調整配置, 可模擬不同裝置環境並優化解析效能. /// -/// ### 效能優化建議 -/// - 將 `blockMedia` 設為 `true` 以防止載入影片或音訊檔, 這能顯著減少流量並提升載入速度. -/// - 針對重度依賴 JavaScript 的單頁應用 (SPA), 建議適度調高 `timeout` 設定. +/// ### 效能建議 +/// - `blockMedia` 預設為 `true`, 可阻擋影片與音訊載入, 顯著降低資料消耗並加快渲染速度. +/// - 對重度依賴 JavaScript 的 SPA, 可適度調高 `timeout`. +/// - `executionJS` 建議撰寫精確的腳本只回傳所需資料, 避免使用預設的全頁 HTML. +/// +/// ### 安全性注意事項 +/// - `isInspectable` 在 Debug build 預設為 `true`, Release build 預設為 `false`. +/// 正式版 App 不應開啟此選項, 以防止任何人透過 Safari 遠端偵錯存取 WebView 內容. public struct WebParserConfig { - /// 目標網址的組成組件. - public var components: URLComponents + // MARK: - Properties + + /// 目標網頁的 URL. + public var url: URL - /// 模擬的瀏覽器類型標識. + /// 模擬的瀏覽器類型標識. 預設為 iOS. public var userAgent: WebParserUserAgent - /// 頁面載入完成後執行的 JavaScript 腳本. 預設回傳全頁面的 HTML 原始碼. + /// 頁面載入完成後執行的 JavaScript 腳本. + /// + /// 預設回傳完整 HTML 原始碼 (`document.documentElement.outerHTML`). + /// 推薦使用 Swift 的 `#"""..."""#` Raw String 語法, 避免跳脫字元維護問題. public var executionJS: String - /// 當解析失敗或網路逾時時的最大重試次數. + /// 解析失敗時的最大重試次數. 實際執行次數為 `maxRetryCount + 1`. 預設為 3. public var maxRetryCount: Int - /// 兩次重試之間的等待時間. + /// 兩次重試之間的等待時長. 預設為 2 秒. public var retryInterval: Duration - /// 單次網頁加載任務的逾時限制. + /// 單次網頁載入的逾時限制. 同時套用於 HTTP 請求逾時與 JavaScript 輪詢總時限. 預設為 30 秒. public var timeout: Duration - /// 是否自動將 App 系統內的 Cookie 同步至網頁環境中. + /// 是否在解析前自動將系統 Cookie 同步至 WebKit. 預設為 `true`. + /// + /// 啟用後, 框架只同步與目標網域相符的 Cookie, 不會影響其他 domain 的資料. public var shouldAutoInjectCookies: Bool - /// 是否允許使用 Safari 遠端開發者工具進行偵錯. + /// 是否開啟 Safari 遠端開發者工具偵錯. Debug build 預設 `true`, Release build 預設 `false`. + /// + /// > Warning: 正式版請務必關閉, 否則任何連線到同一網路的 Mac 都能透過 Safari 檢視 WebView 內容. public var isInspectable: Bool - /// 是否阻擋圖片以外的多媒體資源 (如影片, 音訊) 載入. + /// 是否阻擋圖片以外的多媒體資源 (影片, 音訊) 載入. 預設為 `true`. public var blockMedia: Bool - /// 離屏渲染時的虛擬視窗大小. 會影響網頁的排版佈局 (RWD). + /// 離屏渲染的虛擬視窗尺寸, 影響 RWD 版面配置. 預設為 iPhone 標準尺寸 375×812. public var windowSize: CGSize + // MARK: - Init + /// 初始化網頁解析配置. /// - Parameters: /// - url: 目標網頁 URL. - /// - userAgent: 要模擬的瀏覽器類型. 預設為 iOS. - /// - executionJS: 網頁加載後要執行的腳本. - /// - maxRetryCount: 失敗時的重試次數. 預設為 3 次. - /// - retryInterval: 重試間隔時間. 預設為 2 秒. + /// - userAgent: 模擬的瀏覽器類型. 預設為 iOS. + /// - executionJS: 網頁載入後執行的 JavaScript 腳本. 預設回傳完整 HTML. + /// - maxRetryCount: 最大重試次數. 預設為 3. + /// - retryInterval: 重試間隔. 預設為 2 秒. /// - timeout: 逾時限制. 預設為 30 秒. - /// - shouldAutoInjectCookies: 是否同步系統 Cookie. 預設為 true. - /// - isInspectable: 是否開啟遠端偵錯. 預設為 true. - /// - blockMedia: 是否阻擋媒體資源. 預設為 true. - /// - windowSize: 虛擬畫布大小. 預設為 iPhone 典型的 375x812. + /// - shouldAutoInjectCookies: 是否同步系統 Cookie. 預設為 `true`. + /// - isInspectable: 是否開啟遠端偵錯. Debug 預設 `true`, Release 預設 `false`. + /// - blockMedia: 是否阻擋媒體資源. 預設為 `true`. + /// - windowSize: 虛擬視窗尺寸. 預設為 375×812. public init( url: URL, userAgent: WebParserUserAgent = .iOS(), @@ -88,14 +107,20 @@ public struct WebParserConfig { retryInterval: Duration = .seconds(2), timeout: Duration = .seconds(30), shouldAutoInjectCookies: Bool = true, - isInspectable: Bool = true, + isInspectable: Bool = { + #if DEBUG + return true + #else + return false + #endif + }(), blockMedia: Bool = true, windowSize: CGSize = .init(width: 375, height: 812), ) { - self.components = URLComponents(url: url, resolvingAgainstBaseURL: false) ?? URLComponents() + self.url = url self.userAgent = userAgent self.executionJS = executionJS - self.maxRetryCount = maxRetryCount + self.maxRetryCount = max(0, maxRetryCount) self.retryInterval = retryInterval self.timeout = timeout self.shouldAutoInjectCookies = shouldAutoInjectCookies diff --git a/WebParser/Sources/WebParser/WebParserEngine.swift b/WebParser/Sources/WebParser/WebParserEngine.swift index 199d3ec..5e2712a 100644 --- a/WebParser/Sources/WebParser/WebParserEngine.swift +++ b/WebParser/Sources/WebParser/WebParserEngine.swift @@ -7,152 +7,240 @@ import WebKit -/// 負責處理 WKWebView 離屏渲染與 JavaScript 執行的底層引擎. +/// 負責處理 WKWebView 離屏渲染與 JavaScript 輪詢的底層引擎. /// -/// 此類別透過將 WebView 封裝在非同步任務中,實現了對動態網頁內容的擷取. -/// 它不直接向外曝露,而是由 ``WebParser`` 內部調用. +/// 此類別由 ``WebParser`` 內部建立與管理, 不對外公開. +/// 每次解析任務建立一個獨立的實例, 確保 WKWebView 狀態乾淨, 不受前次解析影響. @MainActor -class WebParserEngine: NSObject, WKNavigationDelegate { - /// 用於渲染網頁的離屏 WebView 實例. +final class WebParserEngine: NSObject { + // MARK: - Properties + + /// 解析配置, 由 WebParser 在建立時直接注入, 取代原本透過 parser 間接存取的設計 + private let config: WebParserConfig + + /// 狀態變更回呼, 由 WebParser 傳入並轉發給 delegate, 解除 Engine 對 WebParser 的直接依賴 + private let onStateChange: (WebParserState) -> Void + + /// 離屏渲染的 WKWebView 實例 private var webView: WKWebView? - /// 用於銜接 Swift Concurrency 與 Delegate 回呼的續體. - /// 傳遞 Data 型別以確保執行緒安全並符合 Sendable 規範. + /// 銜接 WKNavigationDelegate 回呼與 Swift Concurrency 的續體 + /// 存取前必須確認非 nil, resume 後立即設為 nil 以防止 double resume private var continuation: CheckedContinuation? - /// 用於回傳解析進度的代理對象. - private weak var delegate: WebParserProgressDelegate? - - /// 發起請求的解析器實例. - private let parser: WebParser + /// 輪詢任務的引用, 保存此引用才能在 cleanup 時強制取消, 避免逾時前持續佔用資源 + private var pollingTask: Task? - /// 用於監控 WKWebView 載入進度的 KVO 觀察對象. + /// 監聽 WKWebView 載入進度的 KVO 觀察者 private var observation: NSKeyValueObservation? - /// 初始化解析引擎. - /// - Parameters: - /// - delegate: 進度追蹤代理. - /// - parser: 所屬的 WebParser 實例. - init(delegate: WebParserProgressDelegate?, parser: WebParser) { - self.delegate = delegate - self.parser = parser + // MARK: - Init + + init(config: WebParserConfig, onStateChange: @escaping (WebParserState) -> Void) { + self.config = config + self.onStateChange = onStateChange } - /// 執行網頁擷取任務. - /// - Parameter config: 解析所需的各項配置參數. - /// - Returns: JavaScript 執行後的原始資料 (Data). - /// - Throws: `URLError` 或網頁加載過程中的相關錯誤. - func fetch(_ config: WebParserConfig) async throws -> Data { - guard let url = config.components.url else { - throw URLError(.badURL) + // MARK: - Fetch + + /// 執行網頁擷取並回傳 JavaScript 執行後序列化的原始資料. + /// - Returns: JavaScript 結果序列化後的 `Data`. + /// - Throws: 網頁載入失敗拋出 `URLError` 或 `WKError`, 任務取消拋出 `CancellationError`. + func fetch() async throws -> Data { + let wv = buildWebView() + self.webView = wv + + let timeoutInterval = TimeInterval(config.timeout.components.seconds) + wv.load(URLRequest(url: config.url, timeoutInterval: timeoutInterval)) + + // withTaskCancellationHandler 確保外部 Task.cancel() 能立即中斷等待中的 continuation, + // 而不是等到輪詢逾時才結束, 避免不必要的 WKWebView 資源佔用 + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { self.continuation = $0 } + } onCancel: { + // onCancel 可能在非 MainActor 的執行緒觸發, 切回 MainActor 確保安全存取屬性 + Task { @MainActor [weak self] in + self?.resumeWith(.failure(CancellationError())) + } } + } + // MARK: - Private Setup + + /// 根據 config 建立並配置 WKWebView, 套用所有效能與安全性設定 + private func buildWebView() -> WKWebView { let webConfig = WKWebViewConfiguration() webConfig.websiteDataStore = WebParserSession.shared.dataStore - // 若設定阻擋媒體,則強制所有媒體類型需使用者觸發才播放,進而達到阻擋效果. if config.blockMedia { + // 強制所有媒體類型需使用者手動觸發才播放, 等效於阻擋自動載入媒體 webConfig.mediaTypesRequiringUserActionForPlayback = .all } - let wv = WKWebView(frame: .init(origin: .zero, size: config.windowSize), configuration: webConfig) + let wv = WKWebView( + frame: CGRect(origin: .zero, size: config.windowSize), + configuration: webConfig, + ) wv.navigationDelegate = self wv.customUserAgent = config.userAgent.value - // 支援 iOS 16.4+ 的 Safari 遠端偵錯功能. if #available(iOS 16.4, *) { wv.isInspectable = config.isInspectable } - // 透過 KVO 監控載入進度並回傳給代理. + // 透過 KVO 監聽載入進度, 轉換為 .loading 狀態通知代理 + // WKWebView 的 estimatedProgress KVO 保證在 MainActor 上觸發, 使用 assumeIsolated 直接呼叫 observation = wv.observe(\.estimatedProgress, options: [.new]) { [weak self] _, change in - guard let self, let progress = change.newValue else { + guard let progress = change.newValue else { return } - Task { @MainActor in - self.delegate?.webParser(self.parser, didUpdateState: .loading(progress: progress)) + MainActor.assumeIsolated { + self?.onStateChange(.loading(progress: progress)) } } - self.webView = wv - let timeoutInterval = TimeInterval(config.timeout.components.seconds) - wv.load(URLRequest(url: url, timeoutInterval: timeoutInterval)) + return wv + } + + // MARK: - Continuation Helpers + + /// 線程安全的 Continuation 恢復輔助方法. + /// 呼叫後立即將 continuation 設為 nil, 防止後續重複 resume 造成 crash. + private func resumeWith(_ result: Result) { + guard let continuation else { + return + } - // 懸掛當前任務,直到網頁載入完成並回傳結果. - return try await withCheckedThrowingContinuation { self.continuation = $0 } + // 先清空再 resume, 確保即使有競爭條件也只會執行一次 + self.continuation = nil + cleanup() + + switch result { + case let .success(data): continuation.resume(returning: data) + case let .failure(error): continuation.resume(throwing: error) + } + } + + // MARK: - Cleanup + + /// 釋放所有資源, 包含強制取消輪詢任務, 防止逾時前持續佔用記憶體 + private func cleanup() { + pollingTask?.cancel() + pollingTask = nil + observation?.invalidate() + observation = nil + webView?.navigationDelegate = nil + webView = nil } +} - // MARK: - WKNavigationDelegate +// MARK: - WKNavigationDelegate - /// 當網頁初步載入完成後觸發,開始進入 JavaScript 輪詢階段. +extension WebParserEngine: WKNavigationDelegate { + /// 網頁主框架載入完成, 啟動 JavaScript 輪詢任務 func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - Task { @MainActor in - guard let config = parser.currentConfig else { + let js = config.executionJS + let timeout = TimeInterval(config.timeout.components.seconds) + onStateChange(.executingJavaScript) + + // 保存任務引用以便在 cleanup() 時取消, 防止任務在背景繼續運行至逾時 + pollingTask = Task { @MainActor [weak self] in + guard let self else { return } - let startTime = Date() - let timeoutSeconds = Double(config.timeout.components.seconds) - let js = config.executionJS + await runPollingLoop(webView: webView, js: js, timeout: timeout) + } + } + + /// 導航請求在開始載入前失敗 (例如 DNS 錯誤, 無效 URL) + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error, + ) { + resumeWith(.failure(error)) + } + + /// 網頁在載入過程中失敗 (例如逾時, 連線中斷) + func webView( + _ webView: WKWebView, + didFail navigation: WKNavigation!, + withError error: Error, + ) { + resumeWith(.failure(error)) + } +} - delegate?.webParser(parser, didUpdateState: .executingJavaScript) +// MARK: - Polling - // 輪詢機制:持續執行 JS 直到取得預期資料或逾時為止. - while Date().timeIntervalSince(startTime) < timeoutSeconds { - do { - // 執行自定義 JavaScript 腳本. - let rawResult = try await webView.evaluateJavaScript(js) +extension WebParserEngine { + /// JavaScript 輪詢迴圈. 持續執行直到取得有效結果, 逾時或被取消. + private func runPollingLoop(webView: WKWebView, js: String, timeout: TimeInterval) async { + let startTime = Date() - // 驗證回傳結果是否存在且有效. - if let result = rawResult, isResultPresent(result) { - // 將結果轉換為 Data 以便後續 Mapper 處理. - let data = try JSONSerialization.data(withJSONObject: result, options: [.fragmentsAllowed]) + while !Task.isCancelled, Date().timeIntervalSince(startTime) < timeout { + do { + let rawResult = try await webView.evaluateJavaScript(js) - self.continuation?.resume(returning: data) - self.cleanup() - return + if let result = rawResult, isResultPresent(result) { + do { + // 將 JS 結果序列化為 Data, 作為 Sendable 安全的跨邊界傳輸格式 + let data = try JSONSerialization.data(withJSONObject: result, options: .fragmentsAllowed) + resumeWith(.success(data)) } + catch { + // 資料無法序列化, 屬致命錯誤, 重試也無法修復 + resumeWith(.failure(error)) + } + return } - catch { - // JS 執行失敗時繼續輪詢,直到逾時. - } - try? await Task.sleep(for: .seconds(1)) + // 結果為空代表 DOM 尚未就緒 (常見於 SPA 非同步渲染), 繼續輪詢 + } + catch let wkError as WKError where isFatalJSError(wkError) { + // JS 語法錯誤或回傳類型不支援, 屬開發者錯誤, 直接失敗以提供明確的錯誤訊息 + resumeWith(.failure(wkError)) + return + } + catch { + // 其他非致命錯誤 (例如 WebView 尚在初始化), 繼續輪詢 } - // 若達到逾時上限仍未取得資料. - self.continuation?.resume(throwing: URLError(.timedOut)) - self.cleanup() + // 每輪間隔 1 秒, 使用可取消的 sleep 確保能立即響應 Task.cancel() + do { + try await Task.sleep(for: .seconds(1)) + } + catch { + // Task 被取消, 退出輪詢 + break + } } - } - /// 檢查 JavaScript 回傳的結果是否包含實質內容. - /// - Parameter result: 來自 JavaScript 的任意型別結果. - /// - Returns: 布林值,代表結果是否有效. - private func isResultPresent(_ result: Any?) -> Bool { - if let str = result as? String { - return !str.isEmpty + // 非取消原因退出代表已逾時 + guard !Task.isCancelled else { + return } - if let arr = result as? [Any] { - return !arr.isEmpty - } - if let dict = result as? [String: Any] { - return !dict.isEmpty - } - return result != nil + + resumeWith(.failure(URLError(.timedOut))) } - /// 當網頁導向過程中發生錯誤時觸發. - func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { - continuation?.resume(throwing: error) - cleanup() + /// 判斷 WKError 是否為無法透過重試修復的 JavaScript 致命錯誤. + /// + /// - `javaScriptExceptionOccurred`: JS 執行期間拋出例外 (語法錯誤, 未定義變數等) + /// - `javaScriptResultTypeIsUnsupported`: JS 回傳了無法序列化為 JSON 的類型 (例如 Function) + private func isFatalJSError(_ error: WKError) -> Bool { + error.code == .javaScriptExceptionOccurred || error.code == .javaScriptResultTypeIsUnsupported } - /// 釋放資源與清理狀態,防止記憶體洩漏. - private func cleanup() { - observation?.invalidate() - webView?.navigationDelegate = nil - webView = nil - continuation = nil + /// 判斷 JavaScript 回傳的結果是否包含實質內容. + /// 空字串, 空陣列, 空字典均視為「尚未就緒」, 繼續等待. + private func isResultPresent(_ result: Any) -> Bool { + switch result { + case let str as String: !str.isEmpty + case let arr as [Any]: !arr.isEmpty + case let dict as [String: Any]: !dict.isEmpty + default: true + } } } diff --git a/WebParser/Sources/WebParser/WebParserMapper.swift b/WebParser/Sources/WebParser/WebParserMapper.swift index a803ea3..1107e0c 100644 --- a/WebParser/Sources/WebParser/WebParserMapper.swift +++ b/WebParser/Sources/WebParser/WebParserMapper.swift @@ -9,53 +9,79 @@ import Foundation /// 定義資料映射邏輯的協定. /// -/// 透過實作此協定,你可以自定義如何將 `WebParserEngine` 擷取到的原始 `Data` -/// 轉換為具備型別安全的 Swift `Codable` 模型. +/// 透過實作此協定, 你可以自訂如何將 ``WebParserEngine`` 回傳的原始 `Data` +/// 轉換為所需的 Swift 模型. 框架提供兩種內建實作: +/// - ``WebParserJSJSONMapper``: 適用於 JS 回傳 JSON 結構的情境. +/// - ``WebParserRegexMapper``: 適用於 JS 回傳純文字需要自訂處理邏輯的情境. +/// +/// ### 自訂 Mapper 範例 +/// ```swift +/// struct HTMLMapper: WebParserMapper { +/// func map(result: Data) throws -> [String] { +/// // 使用 SwiftSoup 或其他 HTML 解析器 +/// } +/// } +/// ``` public protocol WebParserMapper { - /// 映射目標的模型類型. 必須符合 `Codable` 規範. - associatedtype T: Codable + /// 映射結果的目標類型. + associatedtype T - /// 執行從原始資料到模型物件的轉換邏輯. - /// - Parameter result: 來自引擎執行 JavaScript 後序列化的 `Data`. - /// - Returns: 轉換後的泛型模型物件 `T`. - /// - Throws: 拋出解析或解碼過程中的錯誤. + /// 將原始 `Data` 轉換為目標模型. + /// - Parameter result: 來自引擎執行 JavaScript 後序列化的原始資料. + /// - Returns: 轉換後的模型物件. + /// - Throws: 解析或解碼過程中發生的錯誤. func map(result: Data) throws -> T } -/// 專門用於處理 JavaScript 原生對象 (Array/Object) 或 JSON 字串的轉換器. +/// 將 JavaScript 回傳的 JSON 資料直接解碼為 `Decodable` 模型的轉換器. +/// +/// 適用於 `executionJS` 回傳 JavaScript Object 或 Array 的情境. +/// 引擎內部已透過 `JSONSerialization` 將 JS 結果序列化為 `Data`, +/// 此 Mapper 再負責最終的 `JSONDecoder` 解碼. /// -/// 此轉換器適用於當您的 `executionJS` 回傳的是結構化資料時. -/// 引擎會自動將結果序列化為 `Data`,此對應器則負責執行最後的 JSON 解碼. -public struct WebParserJSJSONMapper: WebParserMapper { +/// ### 使用範例 +/// ```swift +/// struct Comic: Decodable { let title: String } +/// let results = try await parser.parse( +/// with: config, +/// mapper: WebParserJSJSONMapper<[Comic]>() +/// ) +/// ``` +public struct WebParserJSJSONMapper: WebParserMapper { /// 初始化 JS-JSON 轉換器. public init() {} - /// 執行 JSON 解碼. - /// - Parameter result: 引擎傳回的序列化資料. - /// - Returns: 指定的 `Codable` 模型. + /// 使用 `JSONDecoder` 將資料解碼為指定的 `Decodable` 模型. public func map(result: Data) throws -> T { - // 引擎層級已透過 JSONSerialization 處理好 Data,此處直接使用 JSONDecoder. try JSONDecoder().decode(T.self, from: result) } } -/// 提供使用正規表達式 (Regex) 或高度自定義邏輯來處理結果的轉換器. +/// 透過自訂閉包處理原始資料的通用轉換器. +/// +/// 適用於 `executionJS` 回傳純文字 (例如完整 HTML 或 `document.title`) 的情境. +/// 你可以在 `extractor` 閉包中使用正規表達式, 字串處理或任意邏輯來擷取所需內容. +/// 回傳類型不限制為 `Codable`, 可以是任意 Swift 類型. /// -/// 當 `executionJS` 回傳的是純文字 (如全頁面 HTML) 時, -/// 您可以透過此轉換器提供的 `extractor` 閉包進行字串擷取或複雜的邏輯處理. -public struct WebParserRegexMapper: WebParserMapper { - /// 封裝自定義解析邏輯的閉包. +/// ### 使用範例 +/// ```swift +/// let mapper = WebParserRegexMapper { data in +/// let raw = String(data: data, encoding: .utf8) ?? "" +/// // 移除 JS 回傳字串時可能夾帶的 JSON 引號 +/// return raw.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) +/// } +/// ``` +public struct WebParserRegexMapper: WebParserMapper { + /// 封裝自訂解析邏輯的閉包. private let extractor: (Data) throws -> T - /// 初始化 Regex/自定義轉換器. - /// - Parameter extractor: 接收原始 `Data` 並回傳模型 `T` 的處理邏輯. + /// 初始化自訂轉換器. + /// - Parameter extractor: 接收原始 `Data` 並回傳模型 `T` 的解析邏輯. public init(extractor: @escaping (Data) throws -> T) { self.extractor = extractor } - /// 呼叫封裝的擷取邏輯執行映射. - /// - Parameter result: 引擎傳回的原始資料. - /// - Returns: 擷取並封裝後的模型物件. + /// 呼叫 `extractor` 閉包執行自訂映射. public func map(result: Data) throws -> T { try extractor(result) } diff --git a/WebParser/Sources/WebParser/WebParserSession.swift b/WebParser/Sources/WebParser/WebParserSession.swift index f0fc356..af9dbea 100644 --- a/WebParser/Sources/WebParser/WebParserSession.swift +++ b/WebParser/Sources/WebParser/WebParserSession.swift @@ -1,39 +1,84 @@ // // WebParserSession.swift -// Test +// WebParser // -// Created by Joe Pan on 2026/2/2. +// Created by Joe Pan on 2026/02/02. // import WebKit -/// 負責維護 WebParser 的全局運作環境. +/// 負責管理 WebParser 全域 WebKit 環境的單例類別. /// -/// ## 討論 -/// `WebParserSession` 透過單例模式維護了一個全局的 `WKWebsiteDataStore`, -/// 這確保了在不同的解析任務之間, Cookie 與 快取 (Cache) 能夠被正確地管理與共享. +/// `WebParserSession` 維護一個共用的 `WKWebsiteDataStore`, 確保不同解析任務之間 +/// 的 Cookie 與快取能正確共享, 適合需要保持登入狀態的爬蟲場景. /// -/// > Important: 由於 WebKit API 的特性, 所有的 Session 操作必須在 `@MainActor` 執行. +/// > Important: 此類別使用 App 的預設 WebKit 資料存儲 (`.default()`), 與系統的 WKWebView 共享. +/// > 若不希望爬蟲操作影響 App 的正常 WebKit 行為 (例如嵌入式網頁瀏覽器), +/// > 請自行繼承或修改此類別以使用獨立的 `WKWebsiteDataStore`. @MainActor -public class WebParserSession { - /// 全局共享實例. +public final class WebParserSession { + // MARK: - Shared + + /// 全域共享實例. public static let shared: WebParserSession = .init() - /// WebKit 儲存資料的核心實例. + // MARK: - Properties + + /// WebKit 資料存儲核心實例. + /// 使用 `.default()` 與 App 的 WebKit 環境共享, 讓爬蟲能直接繼承使用者的登入狀態. let dataStore: WKWebsiteDataStore = .default() + // MARK: - Init + private init() {} - /// 將 App 層級的 Cookie 同步到 WebKit 進程中. + // MARK: - Public Methods + + /// 將 App 系統 Cookie 同步至 WebKit 環境, 僅同步與指定網域相符的 Cookie. /// - /// 此方法通常在發送請求前呼叫, 確保爬蟲能以使用者的登入身份訪問網頁. - public func syncSystemCookies() async { - guard let cookies = HTTPCookieStorage.shared.cookies else { + /// 與舊版不同, 此方法只注入與目標網域相關的 Cookie, 避免將其他 domain 的 + /// 敏感資訊 (例如 token, session ID) 洩漏至無關的 WebView 環境. + /// + /// - Parameter domain: 目標主機名稱 (例如 `"www.example.com"`). + /// 傳入 `nil` 時不執行任何同步. + public func syncCookies(for domain: String?) async { + guard let domain, let cookies = HTTPCookieStorage.shared.cookies else { return } - for cookie in cookies { + // 篩選與目標網域匹配的 Cookie + // Cookie.domain 可能帶有前綴點號 (如 `.example.com` 代表適用所有子網域) + let matchedCookies = cookies.filter { isCookieMatch($0, for: domain) } + + for cookie in matchedCookies { await dataStore.httpCookieStore.setCookie(cookie) } } + + /// 清除 WebKit 存儲的所有資料 (Cookie, 快取, LocalStorage 等). + /// + /// 適用於需要全新無狀態環境的爬蟲場景, 或測試之間重置 WebKit 狀態. + public func clearAllData() async { + let dataTypes = WKWebsiteDataStore.allWebsiteDataTypes() + let records = await dataStore.dataRecords(ofTypes: dataTypes) + await dataStore.removeData(ofTypes: dataTypes, for: records) + } + + // MARK: - Private Helpers + + /// 判斷一個 Cookie 是否適用於指定的主機網域. + /// + /// 處理兩種 Cookie domain 格式: + /// - `.example.com`: 適用所有子網域 (www.example.com, api.example.com) + /// - `example.com`: 僅適用於完全匹配的網域 + private func isCookieMatch(_ cookie: HTTPCookie, for host: String) -> Bool { + // 去掉前綴點號以便統一比對 (.example.com → example.com) + let cookieDomain = cookie.domain.hasPrefix(".") + ? String(cookie.domain.dropFirst()) + : cookie.domain + + // 精確匹配 (example.com == example.com) + // 或子網域匹配 (www.example.com 的結尾符合 .example.com) + return host == cookieDomain || host.hasSuffix("." + cookieDomain) + } } diff --git a/WebParser/Tests/WebParserTests/WebParserTests.swift b/WebParser/Tests/WebParserTests/WebParserTests.swift index 9d1b657..dbea10c 100644 --- a/WebParser/Tests/WebParserTests/WebParserTests.swift +++ b/WebParser/Tests/WebParserTests/WebParserTests.swift @@ -7,87 +7,223 @@ import Foundation import Testing +import WebKit @testable import WebParser -/// WebParser 框架的單元測試與整合測試集. -/// -/// 此測試集利用 Swift Testing 框架驗證從基礎配置到真實網頁解析的完整流程. @MainActor struct WebParserTests { - /// 測試 Mapper:驗證 JSON 資料是否能正確映射至 Swift 模型. - /// - /// 此為單元測試,不依賴網路環境. - @Test("驗證 JSON 映射至 MockComic 模型") - func testJSONMapping() throws { - let mapper = WebParserJSJSONMapper<[MockComic]>() + // MARK: - WebParserJSJSONMapper + + @Test("WebParserJSJSONMapper: 正確將 JSON 陣列解碼為模型") + func testJSJSONMapperDecodesArray() throws { let json = #""" - [ - { - "id": "12345", - "title": "測試漫畫", - "cover": "https://example.com/p.jpg", - "note": "連載中", - "lastUpdate": 1700000000 - } - ] + [{"id":"12345","title":"測試漫畫","cover":"https://example.com/p.jpg","note":"連載中","lastUpdate":1700000000}] """# - let data = json.data(using: .utf8)! + let data = try #require(json.data(using: .utf8)) + let result = try WebParserJSJSONMapper<[MockComic]>().map(result: data) - let result = try mapper.map(result: data) - - // 使用 Swift Testing 的 #expect 語法進行斷言 #expect(result.count == 1) #expect(result.first?.id == "12345") #expect(result.first?.title == "測試漫畫") } - /// 測試 Config:驗證配置物件是否正確保存自定義屬性. - @Test("驗證 Config 的視窗大小設定") - func testConfigWindowSize() { - let url = URL(string: "https://google.com")! - let customSize = CGSize(width: 500, height: 1000) - let config = WebParserConfig(url: url, windowSize: customSize) + @Test("WebParserJSJSONMapper: JSON 格式錯誤時應拋出 DecodingError") + func testJSJSONMapperThrowsOnInvalidJSON() throws { + let data = try #require("not valid json".data(using: .utf8)) + #expect(throws: DecodingError.self) { + try WebParserJSJSONMapper().map(result: data) + } + } + + @Test("WebParserJSJSONMapper: 欄位類型不符時應拋出 DecodingError") + func testJSJSONMapperThrowsOnTypeMismatch() throws { + // id 應為 String, 但給 Int 會造成解碼失敗 + let json = #"{"id":12345,"title":"測試","cover":"","note":"","lastUpdate":0}"# + let data = try #require(json.data(using: .utf8)) + #expect(throws: DecodingError.self) { + try WebParserJSJSONMapper().map(result: data) + } + } + + // MARK: - WebParserRegexMapper + + @Test("WebParserRegexMapper: 正確執行自訂擷取邏輯") + func testRegexMapperExtractsString() throws { + // 模擬 JS 回傳帶引號的字串 (JSON 格式的字串值) + let data = try #require(#""Hello, WebParser""#.data(using: .utf8)) + let result = try WebParserRegexMapper { data in + let raw = String(data: data, encoding: .utf8) ?? "" + return raw.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + }.map(result: data) + + #expect(result == "Hello, WebParser") + } + + @Test("WebParserRegexMapper: 擷取器拋出錯誤時應向上傳遞") + func testRegexMapperPropagatesError() { + let data = Data() + #expect(throws: URLError.self) { + try WebParserRegexMapper { _ in + throw URLError(.cannotDecodeContentData) + }.map(result: data) + } + } + + @Test("WebParserRegexMapper: 支援非 Codable 的任意回傳類型") + func testRegexMapperSupportsNonCodableType() throws { + let data = try #require("42".data(using: .utf8)) + let result = try WebParserRegexMapper { data in + Int(String(data: data, encoding: .utf8) ?? "0") ?? 0 + }.map(result: data) + + #expect(result == 42) + } + + // MARK: - WebParserConfig + + @Test("WebParserConfig: 自訂視窗尺寸正確保存") + func testConfigStoresCustomWindowSize() throws { + let url = try #require(URL(string: "https://example.com")) + let config = WebParserConfig(url: url, windowSize: CGSize(width: 1920, height: 1080)) + + #expect(config.windowSize.width == 1920) + #expect(config.windowSize.height == 1080) + } + + @Test("WebParserConfig: 所有預設值符合規格") + func testConfigDefaultValues() throws { + let url = try #require(URL(string: "https://example.com")) + let config = WebParserConfig(url: url) + + #expect(config.url == url) + #expect(config.maxRetryCount == 3) + #expect(config.timeout == .seconds(30)) + #expect(config.retryInterval == .seconds(2)) + #expect(config.blockMedia == true) + #expect(config.shouldAutoInjectCookies == true) + #expect(config.executionJS == "document.documentElement.outerHTML") + // Debug build 下 isInspectable 預設為 true + #expect(config.isInspectable == true) + } + + @Test("WebParserConfig: 直接儲存 URL 而非 URLComponents") + func testConfigStoresURL() throws { + let url = try #require(URL(string: "https://example.com/path?q=1")) + let config = WebParserConfig(url: url) - #expect(config.windowSize.width == 500) - #expect(config.windowSize.height == 1000) + #expect(config.url == url) + #expect(config.url.host == "example.com") + #expect(config.url.path == "/path") } - /// 整合測試:驗證真實網頁爬取與 JavaScript 執行邏輯. - /// - /// > Warning: 此測試需要網路連線且受目標網站狀態影響. - /// 設定了 1 分鐘的時間限制以防止 CI/CD 流程卡死. - @Test("真實網頁解析測試", .timeLimit(.minutes(1))) - func testRealWebsiteParsing() async throws { + // MARK: - WebParserUserAgent + + @Test("WebParserUserAgent.iOS: 無版本號時使用預設 17_0") + func testUserAgentIOSDefault() { + let ua = WebParserUserAgent.iOS() + #expect(ua.value.contains("iPhone")) + #expect(ua.value.contains("17_0")) + } + + @Test("WebParserUserAgent.iOS: 版本號中的點號應替換為底線") + func testUserAgentIOSVersionNormalization() { + let ua = WebParserUserAgent.iOS(version: "17.4") + #expect(ua.value.contains("17_4")) + #expect(!ua.value.contains("17.4")) + } + + @Test("WebParserUserAgent.custom: 完整保留自訂字串不做任何修改") + func testUserAgentCustomPreservesString() { + let custom = "Mozilla/5.0 Custom Agent/1.0" + let ua = WebParserUserAgent.custom(custom) + #expect(ua.value == custom) + } + + // MARK: - WebParserSession + + @Test("WebParserSession: nil domain 不執行同步且不拋出錯誤") + func testSyncCookiesNilDomain() async { + await WebParserSession.shared.syncCookies(for: nil) + } + + @Test("WebParserSession: clearAllData 執行後不殘留任何資料") + func testClearAllData() async { + await WebParserSession.shared.clearAllData() + let records = await WebParserSession.shared.dataStore.dataRecords( + ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), + ) + #expect(records.isEmpty) + } + + @Test("WebParserSession: 相符網域的 Cookie 同步後可在 WebKit 查詢到") + func testSyncCookiesMatchingDomain() async throws { + let cookie = try #require(HTTPCookie(properties: [ + .name: "wp_test_session", + .value: "abc123", + .domain: "example.com", + .path: "/", + ])) + HTTPCookieStorage.shared.setCookie(cookie) + + await WebParserSession.shared.syncCookies(for: "example.com") + + let synced = await WebParserSession.shared.dataStore.httpCookieStore.allCookies() + #expect(synced.contains { $0.name == "wp_test_session" && $0.value == "abc123" }) + + // 清理 + HTTPCookieStorage.shared.deleteCookie(cookie) + await WebParserSession.shared.clearAllData() + } + + @Test("WebParserSession: 不相符網域的 Cookie 不應同步至 WebKit") + func testSyncCookiesNonMatchingDomain() async throws { + let cookie = try #require(HTTPCookie(properties: [ + .name: "wp_other_session", + .value: "xyz789", + .domain: "other.com", + .path: "/", + ])) + HTTPCookieStorage.shared.setCookie(cookie) + await WebParserSession.shared.clearAllData() + + await WebParserSession.shared.syncCookies(for: "example.com") + + let synced = await WebParserSession.shared.dataStore.httpCookieStore.allCookies() + #expect(!synced.contains { $0.name == "wp_other_session" }) + + // 清理 + HTTPCookieStorage.shared.deleteCookie(cookie) + } + + // MARK: - Integration Test + + @Test("整合測試: 真實網頁解析與 JavaScript 執行", .timeLimit(.minutes(1))) + func testRealWebParsing() async throws { let parser = WebParser() - let config = WebParserConfig( - url: URL(string: "https://www.manhuagui.com/update/")!, - userAgent: .custom("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Safari/605.1.15"), - executionJS: js, + let config = try WebParserConfig( + url: #require(URL(string: "https://www.manhuagui.com/update/")), + userAgent: .custom( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Safari/605.1.15", + ), + executionJS: integrationTestJS, windowSize: .init(width: 1920, height: 1080), ) do { - // 執行非同步解析任務 - let result = try await parser.parse( - with: config, - mapper: WebParserJSJSONMapper<[MockComic]>(), - ) - + let result = try await parser.parse(with: config, mapper: WebParserJSJSONMapper<[MockComic]>()) #expect(!result.isEmpty) #expect(result.first?.title != nil) - print("Successfully parsed \(result.count) comics.") + print("整合測試成功, 共解析 \(result.count) 筆漫畫.") } catch { - // 記錄測試失敗原因 - Issue.record("解析應成功,但收到錯誤: \(error)") + Issue.record("整合測試失敗, 可能為網路問題或目標網站結構變動: \(error)") } } } // MARK: - Mock Models -/// 用於測試的虛擬漫畫模型. -private struct MockComic: Codable, Identifiable { +private struct MockComic: Decodable, Identifiable { let id: String let title: String let cover: String @@ -97,9 +233,7 @@ private struct MockComic: Codable, Identifiable { // MARK: - Test Assets -/// 用於測試的 JavaScript 爬取腳本. -/// 針對特定漫畫網站的 HTML 結構設計,提取 ID, 標題, 封面圖與更新時間. -private let js = #""" +private let integrationTestJS = #""" (function() { var results = []; if (typeof $ === 'undefined') return []; @@ -115,7 +249,7 @@ private let js = #""" var comic = {}; var href = item.find('a').attr('href') || ""; - var idMatch = href.match(/\/comic\/(\d+)/); + var idMatch = href.match(/\/comic\/(\d+)/); comic.id = idMatch ? idMatch[1] : href.replace(/\//g, ''); comic.title = item.find('a').attr('title') || item.find('dt').text().trim() || "未知漫畫"; diff --git a/WebParserDemo/WebParserDemo.xcodeproj/project.pbxproj b/WebParserDemo/WebParserDemo.xcodeproj/project.pbxproj index 777bb77..db1cc15 100644 --- a/WebParserDemo/WebParserDemo.xcodeproj/project.pbxproj +++ b/WebParserDemo/WebParserDemo.xcodeproj/project.pbxproj @@ -105,7 +105,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 2620; - LastUpgradeCheck = 2620; + LastUpgradeCheck = 2640; TargetAttributes = { D80A54752F3057A60052802D = { CreatedOnToolsVersion = 26.2; @@ -284,7 +284,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = WebParserDemo/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; @@ -317,7 +317,7 @@ ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = WebParserDemo/Info.plist; - INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; diff --git a/WebParserDemo/WebParserDemo/App/AppDelegate.swift b/WebParserDemo/WebParserDemo/App/AppDelegate.swift new file mode 100644 index 0000000..abf54a5 --- /dev/null +++ b/WebParserDemo/WebParserDemo/App/AppDelegate.swift @@ -0,0 +1,28 @@ +// +// AppDelegate.swift +// WebParserDemo +// +// Created by Joe Pan on 2026/02/02. +// + +import UIKit + +@main +final class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?, + ) -> Bool { + true + } + + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions, + ) -> UISceneConfiguration { + let config = UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + config.delegateClass = SceneDelegate.self + return config + } +} diff --git a/WebParserDemo/WebParserDemo/App/SceneDelegate.swift b/WebParserDemo/WebParserDemo/App/SceneDelegate.swift new file mode 100644 index 0000000..6e755ca --- /dev/null +++ b/WebParserDemo/WebParserDemo/App/SceneDelegate.swift @@ -0,0 +1,59 @@ +// +// SceneDelegate.swift +// WebParserDemo +// +// Created by Joe Pan on 2026/02/02. +// + +import UIKit + +final class SceneDelegate: UIResponder, UIWindowSceneDelegate { + // MARK: - UIWindowSceneDelegate + + var window: UIWindow? + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions, + ) { + guard let windowScene = scene as? UIWindowScene else { + return + } + + let window = UIWindow(windowScene: windowScene) + window.rootViewController = makeRootViewController() + window.makeKeyAndVisible() + self.window = window + } +} + +// MARK: - Private + +private extension SceneDelegate { + func makeRootViewController() -> UITabBarController { + let tabBar = UITabBarController() + tabBar.viewControllers = [makeTitleTestNav(), makeComicListNav()] + return tabBar + } + + func makeTitleTestNav() -> UINavigationController { + let vc = TitleTestHostController(viewModel: .init()) + vc.tabBarItem = UITabBarItem( + title: "標題測試", + image: UIImage(systemName: "text.magnifyingglass"), + tag: 0, + ) + return UINavigationController(rootViewController: vc) + } + + func makeComicListNav() -> UINavigationController { + let vc = ComicListHostController(viewModel: .init()) + vc.tabBarItem = UITabBarItem( + title: "漫畫更新", + image: UIImage(systemName: "books.vertical"), + tag: 1, + ) + return UINavigationController(rootViewController: vc) + } +} diff --git a/WebParserDemo/WebParserDemo/ComicList/ComicListHostController.swift b/WebParserDemo/WebParserDemo/ComicList/ComicListHostController.swift new file mode 100644 index 0000000..298ef32 --- /dev/null +++ b/WebParserDemo/WebParserDemo/ComicList/ComicListHostController.swift @@ -0,0 +1,29 @@ +// +// ComicListHostController.swift +// WebParserDemo +// +// Created by Joe Pan on 2026/02/02. +// + +import SwiftUI +import UIKit + +@MainActor +final class ComicListHostController: UIHostingController { + // MARK: - ViewModel + + let viewModel: ComicListViewModel + + // MARK: - Init + + init(viewModel: ComicListViewModel) { + self.viewModel = viewModel + let view = ComicListView(viewModel: viewModel) + super.init(rootView: view) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WebParserDemo/WebParserDemo/ComicList/ComicListView.swift b/WebParserDemo/WebParserDemo/ComicList/ComicListView.swift new file mode 100644 index 0000000..c8c2713 --- /dev/null +++ b/WebParserDemo/WebParserDemo/ComicList/ComicListView.swift @@ -0,0 +1,135 @@ +// +// ComicListView.swift +// WebParserDemo +// +// Created by Joe Pan on 2026/02/02. +// + +import SwiftUI + +struct ComicListView: View { + // MARK: - ViewModel + + let viewModel: ComicListViewModel + + // MARK: - Body + + var body: some View { + Group { + switch viewModel.state.loadingState { + case let .loading(progress) where viewModel.state.comics.isEmpty: + LoadingSection(progress: progress) + + case let .failure(message): + ErrorSection(message: message) { action in + switch action { + case .retryDidTap: + Task { await viewModel.doAction(.view(.retryDidTap)) } + } + } + + default: + ListSection(comics: viewModel.state.comics) + .refreshable { + await viewModel.doAction(.view(.refreshDidTrigger)) + } + } + } + .navigationTitle("最新漫畫更新") + .task { + await viewModel.doAction(.view(.onAppear)) + } + } +} + +private extension ComicListView { + struct LoadingSection: View { + let progress: Double + + var body: some View { + VStack(spacing: 20) { + ProgressView(value: progress, total: 1.0) + .progressViewStyle(.linear) + .padding() + Text("正在同步漫畫數據... \(Int(progress * 100))%") + .foregroundStyle(.secondary) + } + } + } +} + +private extension ComicListView { + struct ErrorSection: View { + enum Action: Sendable { + case retryDidTap + } + + let message: String + let send: (Action) -> Void + + var body: some View { + ContentUnavailableView( + "解析失敗", + systemImage: "exclamationmark.triangle", + description: Text(message), + ) + .toolbar { + ToolbarItem(placement: .bottomBar) { + Button("重試") { + send(.retryDidTap) + } + } + } + } + } +} + +private extension ComicListView { + struct ListSection: View { + let comics: [ComicListViewModel.Comic] + + var body: some View { + List(comics) { comic in + ListRow(comic: comic) + } + .listStyle(.plain) + } + } + + struct ListRow: View { + let comic: ComicListViewModel.Comic + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(comic.title) + .font(.headline) + .lineLimit(1) + Spacer() + Text(formatDate(comic.lastUpdate)) + .font(.caption2) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.blue.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + Text("最新更新: \(comic.note)") + .font(.subheadline) + .foregroundStyle(.blue) + } + .padding(.vertical, 4) + } + + private func formatDate(_ timestamp: Double) -> String { + let date = Date(timeIntervalSince1970: timestamp) + return Self.relativeDateFormatter.localizedString(for: date, relativeTo: Date()) + } + + /// formatter 建立成本高,使用 static 確保整個 View 類型只建立一次 + private static let relativeDateFormatter: RelativeDateTimeFormatter = { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + return formatter + }() + } +} diff --git a/WebParserDemo/WebParserDemo/ComicList/ComicListViewModel+Models.swift b/WebParserDemo/WebParserDemo/ComicList/ComicListViewModel+Models.swift new file mode 100644 index 0000000..b081783 --- /dev/null +++ b/WebParserDemo/WebParserDemo/ComicList/ComicListViewModel+Models.swift @@ -0,0 +1,42 @@ +// +// ComicListViewModel+Models.swift +// WebParserDemo +// +// Created by Joe Pan on 2026/02/02. +// + +import Foundation + +// MARK: - State + +extension ComicListViewModel { + struct State: Sendable { + var comics: [Comic] = [] + var loadingState: LoadingState = .idle + } + + /// LoadingState 僅被 State.loadingState 使用,屬 State 的 L2 + enum LoadingState: Sendable { + /// 初始或已完成狀態. + case idle + /// 載入中,附帶進度值 0.0–1.0. + case loading(Double) + /// 載入成功. + case success + /// 載入失敗,附帶錯誤訊息. + case failure(String) + } +} + +// MARK: - Domain Models + +extension ComicListViewModel { + /// 描述漫畫更新資訊的領域模型. + struct Comic: Identifiable, Sendable, Decodable { + let id: String + let title: String + let cover: String + let note: String + let lastUpdate: Double + } +} diff --git a/WebParserDemo/WebParserDemo/ComicList/ComicListViewModel.swift b/WebParserDemo/WebParserDemo/ComicList/ComicListViewModel.swift new file mode 100644 index 0000000..5d166f3 --- /dev/null +++ b/WebParserDemo/WebParserDemo/ComicList/ComicListViewModel.swift @@ -0,0 +1,188 @@ +// +// ComicListViewModel.swift +// WebParserDemo +// +// Created by Joe Pan on 2026/02/02. +// + +import Foundation +import Observation +import WebParser + +@Observable +@MainActor +final class ComicListViewModel { + // MARK: - ViewModel + + enum Action: Sendable { + case view(ViewAction) + case apiRequest(APIRequest) + case apiResponse(APIResponse) + } + + // MARK: - State + + var state: State = .init() + + // MARK: - Private + + @ObservationIgnored + private let parser: WebParser = .init() + + // MARK: - Init + + init() { + parser.delegate = self + } + + func doAction(_ action: Action) async { + switch action { + case let .view(action): await handleViewAction(action) + case let .apiRequest(action): await handleAPIRequest(action) + case let .apiResponse(action): handleAPIResponse(action) + } + } +} + +extension ComicListViewModel: WebParserProgressDelegate { + func webParser(_ parser: WebParser, didUpdateState parserState: WebParserState) { + switch parserState { + case let .loading(progress): + state.loadingState = .loading(progress) + case let .retrying(attempt, error): + print("第 \(attempt) 次重試, 原因: \(error.localizedDescription)") + default: + break + } + } +} + +// MARK: - ViewAction + +extension ComicListViewModel { + enum ViewAction: Sendable { + case onAppear + case refreshDidTrigger + case retryDidTap + } + + private func handleViewAction(_ action: ViewAction) async { + switch action { + case .onAppear: + guard state.comics.isEmpty else { + return + } + + await doAction(.apiRequest(.fetchComics)) + + case .refreshDidTrigger, .retryDidTap: + await doAction(.apiRequest(.fetchComics)) + } + } +} + +// MARK: - APIRequest + +extension ComicListViewModel { + private static let comicJS = #""" + (function() { + var results = []; + if (typeof $ === 'undefined') return []; + + var list = $('.latest-list > ul > li'); + if (list.length === 0) return []; + + var count = list.length; + var now = Math.floor(Date.now() / 1000); + + list.each(function() { + var item = $(this); + var comic = {}; + + var href = item.find('a').attr('href') || ""; + var idMatch = href.match(/\/comic\/(\d+)/); + comic.id = idMatch ? idMatch[1] : href.replace(/\//g, ''); + + comic.title = item.find('a').attr('title') || item.find('dt').text().trim() || "未知漫畫"; + + var img = item.find('img'); + comic.cover = img.attr('data-src') || img.attr('src') || ""; + + comic.note = item.find('.tt').text().trim(); + + var dateStr = item.find('em').text().trim(); + var timestamp = 0; + + if (dateStr.includes('今天')) { + timestamp = now; + } else if (dateStr.includes('昨天')) { + timestamp = now - 86400; + } else { + var parsed = Date.parse(dateStr); + timestamp = isNaN(parsed) ? now : Math.floor(parsed / 1000); + } + + comic.lastUpdate = timestamp + count; + results.push(comic); + count--; + }); + + return results; + })(); + """# + + enum APIRequest: Sendable { + case fetchComics + } + + private func handleAPIRequest(_ action: APIRequest) async { + switch action { + case .fetchComics: + await fetchComics() + } + } + + private func fetchComics() async { + state.loadingState = .loading(0) + + let config = WebParserConfig( + url: URL(string: "https://www.manhuagui.com/update/")!, + userAgent: .custom( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Safari/605.1.15", + ), + executionJS: Self.comicJS, + windowSize: CGSize(width: 1920, height: 1080), + ) + + do { + let comics = try await parser.parse(with: config, mapper: WebParserJSJSONMapper<[Comic]>()) + await doAction(.apiResponse(.fetchComicsSuccess(comics))) + } + catch is CancellationError { + state.loadingState = .idle + } + catch { + await doAction(.apiResponse(.fetchComicsFailure(error.localizedDescription))) + } + } +} + +// MARK: - APIResponse + +extension ComicListViewModel { + enum APIResponse: Sendable { + case fetchComicsSuccess([Comic]) + case fetchComicsFailure(String) + } + + private func handleAPIResponse(_ action: APIResponse) { + switch action { + case let .fetchComicsSuccess(comics): + state.comics = comics + state.loadingState = .success + + case let .fetchComicsFailure(message): + state.loadingState = .failure(message) + } + } +} diff --git a/WebParserDemo/WebParserDemo/ComicListView.swift b/WebParserDemo/WebParserDemo/ComicListView.swift deleted file mode 100644 index e198262..0000000 --- a/WebParserDemo/WebParserDemo/ComicListView.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// ComicListView.swift -// WebParserDemo -// -// Created by Joe Pan on 2026/02/02. -// - -import SwiftUI - -/// 顯示最新漫畫更新清單的視圖. -/// -/// 此視圖透過 `ComicListViewModel` 與 `WebParser` 框架互動, -/// 展示了包含進度條、錯誤處理以及下拉刷新的完整 UI 流程. -struct ComicListView: View { - /// 使用 ViewModel 監控資料與狀態變更. - @State private var viewModel: ComicListViewModel = .init() - - var body: some View { - NavigationStack { - Group { - if viewModel.isLoaderShowing { - // 狀態一:載入中 - 顯示進度條與百分比 - VStack(spacing: 20) { - ProgressView(value: viewModel.progress, total: 1.0) - .progressViewStyle(.linear) - .padding() - Text("正在同步漫畫數據... \(Int(viewModel.progress * 100))%") - .foregroundStyle(.secondary) - } - } - else if let error = viewModel.errorMessage { - // 狀態二:解析失敗 - 顯示錯誤訊息與重試按鈕 - ContentUnavailableView( - "解析失敗", - systemImage: "exclamationmark.triangle", - description: Text(error), - ) - .toolbar { - ToolbarItem(placement: .bottomBar) { - Button("重試") { - Task { await viewModel.fetchComics() } - } - } - } - } - else { - // 狀態三:成功載入 - 顯示漫畫列表 - List(viewModel.comics) { comic in - VStack(alignment: .leading, spacing: 8) { - HStack { - Text(comic.title) - .font(.headline) - .lineLimit(1) - Spacer() - // 顯示相對時間 (例如:3 小時前) - Text(formatDate(comic.lastUpdate)) - .font(.caption2) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(Color.blue.opacity(0.1)) - .cornerRadius(4) - } - - Text("最新更新: \(comic.note)") - .font(.subheadline) - .foregroundStyle(.blue) - } - .padding(.vertical, 4) - } - .listStyle(.plain) - .refreshable { - // 支援下拉刷新 - await viewModel.fetchComics() - } - } - } - .navigationTitle("最新漫畫更新") - .onAppear { - // 進入頁面時自動抓取資料 - if viewModel.comics.isEmpty { - Task { await viewModel.fetchComics() } - } - } - } - } - - /// 將時間戳記轉換為易讀的相對時間字串. - /// - Parameter timestamp: Unix 時間戳記. - /// - Returns: 本地化的相對時間描述 (例如:剛剛, 5 分鐘前). - private func formatDate(_ timestamp: Double) -> String { - let date = Date(timeIntervalSince1970: timestamp) - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - return formatter.localizedString(for: date, relativeTo: Date()) - } -} diff --git a/WebParserDemo/WebParserDemo/ComicListViewModel.swift b/WebParserDemo/WebParserDemo/ComicListViewModel.swift deleted file mode 100644 index b17f51f..0000000 --- a/WebParserDemo/WebParserDemo/ComicListViewModel.swift +++ /dev/null @@ -1,149 +0,0 @@ -// -// ComicListViewModel.swift -// WebParserDemo -// -// Created by Joe Pan on 2026/02/02. -// - -import Observation -import SwiftUI -import WebParser - -/// 漫畫更新列表的業務邏輯處理類別. -/// -/// 採用 iOS 17+ 的 `@Observable` 宏, 負責驅動 UI 更新並處理與 `WebParser` 的互動. -@Observable -@MainActor -class ComicListViewModel: WebParserProgressDelegate { - /// 抓取到的漫畫更新清單. - var comics: [ComicUpdate] = [] - /// 是否正在載入中. - var isLoaderShowing = false - /// 當前的加載進度 (0.0 - 1.0). - var progress: Double = 0 - /// 錯誤訊息, 若為 nil 則表示目前無錯誤. - var errorMessage: String? - - /// 內部的解析器實例. - private let parser: WebParser = .init() - - /// 初始化 ViewModel 並設置代理連線. - init() { - parser.delegate = self - } - - /// 發起非同步請求以獲取漫畫更新資料. - func fetchComics() async { - isLoaderShowing = true - errorMessage = nil - - // 設定針對漫畫網站優化的解析配置 - let config = WebParserConfig( - url: URL(string: "https://www.manhuagui.com/update/")!, - userAgent: .custom("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/13.0.5 Safari/605.1.15"), - executionJS: comicJS, - windowSize: .init(width: 1920, height: 1080), - ) - - do { - // 執行解析並使用 JSJSONMapper 自動轉型為模型陣列 - let result = try await parser.parse( - with: config, - mapper: WebParserJSJSONMapper<[ComicUpdate]>(), - ) - self.comics = result - self.isLoaderShowing = false - } - catch { - self.errorMessage = error.localizedDescription - self.isLoaderShowing = false - } - } - - // MARK: - WebParserProgressDelegate - - /// 響應解析器的狀態更新. - /// - Parameters: - /// - parser: 解析器實例. - /// - state: 當前的 ``WebParserState`` 狀態. - func webParser(_ parser: WebParser, didUpdateState state: WebParserState) { - // 透過狀態機統一處理進度與 log - switch state { - case let .loading(currentProgress): - self.progress = currentProgress - case let .failed(error): - print("解析失敗: \(error.localizedDescription)") - case .completed: - print("解析任務圓滿完成") - default: - break - } - } -} - -// MARK: - 模型定義 - -/// 描述漫畫更新資訊的資料模型. -struct ComicUpdate: Codable, Identifiable { - /// 漫畫唯一標識碼. - let id: String - /// 漫畫標題. - let title: String - /// 封面圖片網址. - let cover: String - /// 更新狀態備註 (如: 載至 100 話). - let note: String - /// 最後更新的時間戳記. - let lastUpdate: Double -} - -// MARK: - 爬蟲腳本 (Raw String 模式) - -/// 針對目標漫畫網站設計的 JavaScript 擷取腳本. -/// 利用 jQuery 語法提取清單資訊並計算精確的時間戳記. -private let comicJS = #""" -(function() { - var results = []; - if (typeof $ === 'undefined') return []; - - var list = $('.latest-list > ul > li'); - if (list.length === 0) return []; - - var count = list.length; - var now = Math.floor(Date.now() / 1000); - - list.each(function() { - var item = $(this); - var comic = {}; - - var href = item.find('a').attr('href') || ""; - var idMatch = href.match(/\/comic\/(\d+)/); - comic.id = idMatch ? idMatch[1] : href.replace(/\//g, ''); - - comic.title = item.find('a').attr('title') || item.find('dt').text().trim() || "未知漫畫"; - - var img = item.find('img'); - comic.cover = img.attr('data-src') || img.attr('src') || ""; - - comic.note = item.find('.tt').text().trim(); - - var dateStr = item.find('em').text().trim(); - var timestamp = 0; - - if (dateStr.includes('今天')) { - timestamp = now; - } else if (dateStr.includes('昨天')) { - timestamp = now - 86400; - } else { - var parsed = Date.parse(dateStr); - timestamp = isNaN(parsed) ? now : Math.floor(parsed / 1000); - } - - comic.lastUpdate = timestamp + count; - results.push(comic); - count--; - }); - - return results; -})(); -"""# diff --git a/WebParserDemo/WebParserDemo/ContentView.swift b/WebParserDemo/WebParserDemo/ContentView.swift deleted file mode 100644 index d57e6d1..0000000 --- a/WebParserDemo/WebParserDemo/ContentView.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// ContentView.swift -// WebParserDemo -// -// Created by Joe Pan on 2026/02/02. -// - -import SwiftUI - -/// 應用程式的主導覽視圖. -/// -/// 透過 TabView 整合不同的測試場景,展示 WebParser 的多種用途. -struct ContentView: View { - /// 定義視圖佈局與標籤頁內容. - var body: some View { - TabView { - // 第一頁:基礎標題擷取測試 (驗證單一 DOM 元素擷取) - TitleTestView() - .tabItem { - Label("標題測試", systemImage: "text.magnifyingglass") - } - - // 第二頁:漫畫更新列表 (驗證複雜陣列資料與 JSON 映射) - ComicListView() - .tabItem { - Label("漫畫更新", systemImage: "books.vertical") - } - } - } -} diff --git a/WebParserDemo/WebParserDemo/Info.plist b/WebParserDemo/WebParserDemo/Info.plist index 6a6654d..a5c8390 100644 --- a/WebParserDemo/WebParserDemo/Info.plist +++ b/WebParserDemo/WebParserDemo/Info.plist @@ -7,5 +7,20 @@ NSAllowsArbitraryLoads + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneConfigurationName + Default Configuration + + + + diff --git a/WebParserDemo/WebParserDemo/TitleTest/TitleTestHostController.swift b/WebParserDemo/WebParserDemo/TitleTest/TitleTestHostController.swift new file mode 100644 index 0000000..88736b7 --- /dev/null +++ b/WebParserDemo/WebParserDemo/TitleTest/TitleTestHostController.swift @@ -0,0 +1,29 @@ +// +// TitleTestHostController.swift +// WebParserDemo +// +// Created by Joe Pan on 2026/02/02. +// + +import SwiftUI +import UIKit + +@MainActor +final class TitleTestHostController: UIHostingController { + // MARK: - ViewModel + + let viewModel: TitleTestViewModel + + // MARK: - Init + + init(viewModel: TitleTestViewModel) { + self.viewModel = viewModel + let view = TitleTestView(viewModel: viewModel) + super.init(rootView: view) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/WebParserDemo/WebParserDemo/TitleTest/TitleTestView.swift b/WebParserDemo/WebParserDemo/TitleTest/TitleTestView.swift new file mode 100644 index 0000000..f96e935 --- /dev/null +++ b/WebParserDemo/WebParserDemo/TitleTest/TitleTestView.swift @@ -0,0 +1,106 @@ +// +// TitleTestView.swift +// WebParserDemo +// +// Created by Joe Pan on 2026/02/02. +// + +import SwiftUI + +struct TitleTestView: View { + let viewModel: TitleTestViewModel + + // MARK: - Body + + var body: some View { + @Bindable var viewModel = viewModel + + Form { + URLInputSection( + urlString: $viewModel.state.urlString, + isLoading: { + if case .loading = viewModel.state.fetchResult { + return true + } + return false + }(), + ) { action in + switch action { + case .fetchDidTap: + Task { await viewModel.doAction(.view(.fetchDidTap)) } + } + } + + FetchResultSection(result: viewModel.state.fetchResult) + } + .navigationTitle("WebParser 實測") + } +} + +// MARK: - URLInputSection + +private extension TitleTestView { + struct URLInputSection: View { + enum Action: Sendable { + case fetchDidTap + } + + @Binding var urlString: String + let isLoading: Bool + let send: (Action) -> Void + + var body: some View { + Section("輸入網址") { + TextField("https://...", text: $urlString) + .keyboardType(.URL) + .autocorrectionDisabled() + .textInputAutocapitalization(.never) + + Button { + send(.fetchDidTap) + } label: { + if isLoading { + ProgressView() + .frame(maxWidth: .infinity) + } + else { + Text("開始抓取網頁標題") + .frame(maxWidth: .infinity) + } + } + .disabled(isLoading) + } + } + } +} + +// MARK: - FetchResultSection + +private extension TitleTestView { + struct FetchResultSection: View { + let result: TitleTestViewModel.FetchResult + + var body: some View { + Section("抓取結果") { + switch result { + case .idle: + Text("尚未有資料") + .foregroundStyle(.secondary) + + case .loading: + Text("連線中...") + .foregroundStyle(.secondary) + + case let .success(title): + Text("網頁標題:\n\(title)") + .font(.system(.body, design: .monospaced)) + + case let .failure(message): + Text("錯誤:\(message)") + .font(.system(.body, design: .monospaced)) + .foregroundStyle(.red) + } + } + } + } +} diff --git a/WebParserDemo/WebParserDemo/TitleTest/TitleTestViewModel+Models.swift b/WebParserDemo/WebParserDemo/TitleTest/TitleTestViewModel+Models.swift new file mode 100644 index 0000000..56a77f1 --- /dev/null +++ b/WebParserDemo/WebParserDemo/TitleTest/TitleTestViewModel+Models.swift @@ -0,0 +1,25 @@ +// +// TitleTestViewModel+Models.swift +// WebParserDemo +// +// Created by Joe Pan on 2026/02/02. +// + +import Foundation + +// MARK: - State + +extension TitleTestViewModel { + struct State: Sendable { + var urlString: String = "https://www.apple.com/tw/" + var fetchResult: FetchResult = .idle + } + + /// FetchResult 僅被 State.fetchResult 使用,屬 State 的 L2 + enum FetchResult: Sendable { + case idle + case loading + case success(String) + case failure(String) + } +} diff --git a/WebParserDemo/WebParserDemo/TitleTest/TitleTestViewModel.swift b/WebParserDemo/WebParserDemo/TitleTest/TitleTestViewModel.swift new file mode 100644 index 0000000..a62673b --- /dev/null +++ b/WebParserDemo/WebParserDemo/TitleTest/TitleTestViewModel.swift @@ -0,0 +1,119 @@ +// +// TitleTestViewModel.swift +// WebParserDemo +// +// Created by Joe Pan on 2026/02/02. +// + +import Foundation +import Observation +import WebParser + +@Observable +@MainActor +final class TitleTestViewModel { + // MARK: - ViewModel + + enum Action: Sendable { + case view(ViewAction) + case apiRequest(APIRequest) + case apiResponse(APIResponse) + } + + // MARK: - State + + var state: State = .init() + + // MARK: - Private + + @ObservationIgnored + private let parser: WebParser = .init() + + func doAction(_ action: Action) async { + switch action { + case let .view(action): await handleViewAction(action) + case let .apiRequest(action): await handleAPIRequest(action) + case let .apiResponse(action): handleAPIResponse(action) + } + } +} + +// MARK: - ViewAction + +extension TitleTestViewModel { + enum ViewAction: Sendable { + case fetchDidTap + } + + private func handleViewAction(_ action: ViewAction) async { + switch action { + case .fetchDidTap: + guard let url = URL(string: state.urlString) else { + state.fetchResult = .failure("無效的網址") + return + } + + await doAction(.apiRequest(.fetchTitle(url))) + } + } +} + +// MARK: - APIRequest + +extension TitleTestViewModel { + enum APIRequest: Sendable { + case fetchTitle(URL) + } + + private func handleAPIRequest(_ action: APIRequest) async { + switch action { + case let .fetchTitle(url): + await fetchTitle(url) + } + } + + private func fetchTitle(_ url: URL) async { + state.fetchResult = .loading + + let config = WebParserConfig(url: url, executionJS: "document.title") + + do { + let title = try await parser.parse( + with: config, + mapper: WebParserRegexMapper { data in + guard let raw = String(data: data, encoding: .utf8) else { + throw URLError(.cannotDecodeContentData) + } + + // JS 以 JSON 格式回傳字串時會帶有引號,需去除 + return raw.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + }, + ) + await doAction(.apiResponse(.fetchTitleSuccess(title))) + } + catch is CancellationError { + state.fetchResult = .idle + } + catch { + await doAction(.apiResponse(.fetchTitleFailure(error.localizedDescription))) + } + } +} + +// MARK: - APIResponse + +extension TitleTestViewModel { + enum APIResponse: Sendable { + case fetchTitleSuccess(String) + case fetchTitleFailure(String) + } + + private func handleAPIResponse(_ action: APIResponse) { + switch action { + case let .fetchTitleSuccess(title): + state.fetchResult = .success(title) + case let .fetchTitleFailure(message): + state.fetchResult = .failure(message) + } + } +} diff --git a/WebParserDemo/WebParserDemo/TitleTestView.swift b/WebParserDemo/WebParserDemo/TitleTestView.swift deleted file mode 100644 index d326150..0000000 --- a/WebParserDemo/WebParserDemo/TitleTestView.swift +++ /dev/null @@ -1,111 +0,0 @@ -// -// TitleTestView.swift -// WebParserDemo -// -// Created by Joe Pan on 2026/02/02. -// - -import SwiftUI -import WebParser - -/// 用於測試基礎網頁標題抓取的視圖. -/// -/// 此視圖展示了如何使用 `WebParserRegexMapper` 處理非 JSON 結構的簡單字串回傳. -struct TitleTestView: View { - /// 使用者輸入的網址字串. - @State private var urlString = "https://www.apple.com/tw/" - /// 顯示抓取到的結果或錯誤訊息. - @State private var scrapedResult = "" - /// 控制載入動畫的顯示狀態. - @State private var isLoaderShowing = false - - var body: some View { - NavigationStack { - Form { - Section("輸入網址") { - TextField("https://...", text: $urlString) - .keyboardType(.URL) - .autocorrectionDisabled() - } - - Section("操作") { - Button(action: executeScraping) { - if isLoaderShowing { - ProgressView().center() - } - else { - Text("開始抓取網頁標題") - .frame(maxWidth: .infinity) - } - } - .disabled(isLoaderShowing) - } - - Section("抓取結果") { - Text(scrapedResult.isEmpty ? "尚未有資料" : scrapedResult) - .font(.system(.body, design: .monospaced)) - .foregroundStyle(scrapedResult.contains("錯誤") ? .red : .primary) - } - } - .navigationTitle("WebParser 實測") - } - } - - /// 執行網頁抓取的實戰邏輯. - @MainActor - func executeScraping() { - guard let url = URL(string: urlString) else { - scrapedResult = "錯誤:無效的網址" - return - } - - isLoaderShowing = true - scrapedResult = "連線中..." - - // 1. 初始化 Parser 實例 - let parser = WebParser() - - // 2. 設定 Config (透過 JS 取得頁面標題) - let config = WebParserConfig( - url: url, - executionJS: "document.title", - ) - - // 3. 啟動非同步抓取任務 - Task { - do { - // 使用 WebParserRegexMapper 處理 Data 到 String 的轉換 - let title = try await parser.parse( - with: config, - mapper: WebParserRegexMapper { data in - // 將 Data 轉為 String,若編碼失敗則拋出錯誤 - guard let string = String(data: data, encoding: .utf8) else { - throw URLError(.cannotDecodeContentData) - } - - // 處理 JavaScript 回傳時可能夾帶的 JSON 引號 - return string.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - }, - ) - scrapedResult = "網頁標題:\n\(title)" - } - catch { - scrapedResult = "執行失敗:\n\(error.localizedDescription)" - } - isLoaderShowing = false - } - } -} - -// MARK: - View Extensions - -extension View { - /// 輔助小工具:將視圖水平置中於容器內. - func center() -> some View { - HStack { - Spacer() - self - Spacer() - } - } -} diff --git a/WebParserDemo/WebParserDemo/WebParserDemoApp.swift b/WebParserDemo/WebParserDemo/WebParserDemoApp.swift deleted file mode 100644 index e6055d9..0000000 --- a/WebParserDemo/WebParserDemo/WebParserDemoApp.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// WebParserDemoApp.swift -// WebParserDemo -// -// Created by Joe Pan on 2026/02/02. -// - -import SwiftUI - -/// WebParserDemo 應用程式的主進入點. -/// -/// 此 App 展示了如何將 WebParser 框架整合進 SwiftUI 專案中. -/// 透過觀察者模式與離屏渲染,實現高效且非同步的網頁資料擷取. -@main -struct WebParserDemoApp: App { - /// 定義應用程式的主要場景. - var body: some Scene { - WindowGroup { - // 啟動主視圖 - ContentView() - } - } -}