diff --git a/README.md b/README.md index 4b1245bb..d721a5f1 100644 --- a/README.md +++ b/README.md @@ -203,7 +203,7 @@ We welcome contributions! Please read our [Contributing Guidelines](CONTRIBUTING For any inquiries, feel free to reach out through one of the following channels: -- **Email**: [inknest@mail.p2devs.engineer](mailto:inknest@mail.p2devs.engineer) +- **Email**: [inknest@capacity.rocks](mailto:inknest@capacity.rocks) - **Discord**: Join our community on [Discord](https://discord.gg/WYwJefvWNT) for real-time support and discussion. - **GitHub Discussions**: Visit our [GitHub Discussions board](https://github.com/p2devs/InkNest/discussions) to engage with our community, ask questions, and find answers to common issues. diff --git a/__tests__/novelChapterParser.test.js b/__tests__/novelChapterParser.test.js new file mode 100644 index 00000000..1bd77b87 --- /dev/null +++ b/__tests__/novelChapterParser.test.js @@ -0,0 +1,62 @@ +const cheerio = require('cheerio'); + +const { + parseWTRLabChapter, +} = require('../src/Redux/Actions/parsers/novelChapterParser'); + +describe('parseWTRLabChapter', () => { + let logSpy; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + logSpy.mockRestore(); + }); + + it('parses array-based reader payloads into readable paragraphs', () => { + const $ = cheerio.load(''); + + const result = parseWTRLabChapter( + $, + {}, + { + data: { + data: { + body: ['First paragraph', 'Second paragraph'], + }, + }, + }, + {title: 'Chapter 1'}, + ); + + expect(result.title).toBe('Chapter 1'); + expect(result.paragraphs).toEqual([ + 'First paragraph', + 'Second paragraph', + ]); + expect(result.text).toBe('First paragraph\n\nSecond paragraph'); + }); + + it('supports object entries inside array-based reader payloads', () => { + const $ = cheerio.load(''); + + const result = parseWTRLabChapter( + $, + {}, + { + data: { + data: { + body: [{text: 'Alpha'}, {content: 'Beta'}], + }, + }, + }, + {title: 'Chapter 2'}, + ); + + expect(result.title).toBe('Chapter 2'); + expect(result.paragraphs).toEqual(['Alpha', 'Beta']); + expect(result.text).toBe('Alpha\n\nBeta'); + }); +}); diff --git a/docs/blog/2026-03-28-release-v1.4.9.md b/docs/blog/2026-03-28-release-v1.4.9.md new file mode 100644 index 00000000..9431dd91 --- /dev/null +++ b/docs/blog/2026-03-28-release-v1.4.9.md @@ -0,0 +1,34 @@ +--- +slug: release-v1.4.9 +title: InkNest v1.4.9 Release +authors: [p2devs] +tags: [release] +--- + +# 📢 InkNest v1.4.9 Release Announcement + +Hey everyone! 🎉 We're thrilled to announce **InkNest v1.4.9** is officially here! + +This is a massive update introducing a highly anticipated new feature: the **Novel Reading Module**, along with multi-source tracking, offline support, and various quality-of-life improvements. + + + +## ✨ What's New + +#### 📚 All-New Novel Reading Module +- **Novel Reading Experience** — Dive into your favorite novels with a dedicated, brand-new reading module. +- **Offline Reading Foundation** — We've laid the groundwork for offline support. *(Note: Full offline downloading for novels is currently in development and not yet available for users).* +- **Multi-Source Integration** — Access content from multiple sources effortlessly. *(Note: WTR-Lab integration is included, but the source is currently disabled due to Cloudflare protection).* + +#### 🔔 Source Status Tracking & Notifications +- **Source Status Tracking** — Stay updated on the status of your favorite content sources and get notifications when there are changes or issues. + +## 🐛 Bug Fixes & Improvements +- **Search UI Refinements** — Updated the background color and centered the header title for a cleaner looking Search interface. +- **Code Optimization** — Refactored both the Search and NovelDetails components for improved performance and readability. +- **Bug Fixes** — Removed unnecessary error handling on synchronous notification persistence. +- **Contact Info** — Updated our official contact email to `inknest@capacity.rocks`. + +**Update now** to explore the brand new Novel Reading Module and all the other improvements! 📚✨ + +As always, if you encounter any issues, please report them in our issues channel. Happy reading! 🦋 \ No newline at end of file diff --git a/docs/src/pages/faq.js b/docs/src/pages/faq.js index 82edc7fe..d9210907 100644 --- a/docs/src/pages/faq.js +++ b/docs/src/pages/faq.js @@ -108,7 +108,7 @@ const faqData = [ • **GitHub Issues** - Create a detailed bug report on our [Issues page](https://github.com/p2devs/InkNest/issues) • **Discord** - Report issues in our [Discord server](https://discord.gg/WYwJefvWNT) -• **Email** - Contact us at [inknest@mail.p2devs.engineer](mailto:inknest@mail.p2devs.engineer) +• **Email** - Contact us at [inknest@capacity.rocks](mailto:inknest@capacity.rocks) • **TestFlight** - iOS users can send feedback directly through TestFlight *Please include device info, app version, and steps to reproduce the issue!*`, @@ -157,7 +157,7 @@ const faqData = [ q: 'How can I contact the InkNest team?', a: `Reach out through any of these channels: -• **Email** - [inknest@mail.p2devs.engineer](mailto:inknest@mail.p2devs.engineer) +• **Email** - [inknest@capacity.rocks](mailto:inknest@capacity.rocks) • **Discord** - Join our [Discord server](https://discord.gg/WYwJefvWNT) for real-time chat • **GitHub Discussions** - [Ask questions](https://github.com/p2devs/InkNest/discussions) and share ideas • **GitHub Issues** - For bug reports and feature requests @@ -289,7 +289,7 @@ export default function FAQ() { 💡 GitHub Discussions 📧 Email Us diff --git a/docs/src/pages/index.js b/docs/src/pages/index.js index d4afa1b2..0bf19049 100644 --- a/docs/src/pages/index.js +++ b/docs/src/pages/index.js @@ -239,8 +239,8 @@ function ContactSection() {

📧 Email

- - inknest@mail.p2devs.engineer + + inknest@capacity.rocks
diff --git a/ios/InkNest.xcodeproj/project.pbxproj b/ios/InkNest.xcodeproj/project.pbxproj index bdd00ce3..4b2bd106 100644 --- a/ios/InkNest.xcodeproj/project.pbxproj +++ b/ios/InkNest.xcodeproj/project.pbxproj @@ -379,14 +379,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-frameworks.sh\"\n"; @@ -401,8 +397,6 @@ "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", ); name = "[CP-User] [RNGoogleMobileAds] Configuration"; - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_PROJECT_ABBREVIATION=\"RNGoogleMobileAds\"\n_JSON_ROOT=\"'react-native-google-mobile-ads'\"\n_JSON_FILE_NAME='app.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n_PACKAGE_JSON_NAME='package.json'\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -KU -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> ${_PROJECT_ABBREVIATION} build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -KU -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"python3 not found, app.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"google_mobile_ads_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.delay_app_measurement_init\n _DELAY_APP_MEASUREMENT=$(getJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"delay_app_measurement_init\")\n if [[ $_DELAY_APP_MEASUREMENT == \"true\" ]]; then\n _PLIST_ENTRY_KEYS+=(\"GADDelayAppMeasurementInit\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"YES\")\n fi\n\n # config.ios_app_id\n _IOS_APP_ID=$(getJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"ios_app_id\")\n if [[ $_IOS_APP_ID ]]; then\n _PLIST_ENTRY_KEYS+=(\"GADApplicationIdentifier\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_IOS_APP_ID\")\n fi\n\n # config.sk_ad_network_items\n _SK_AD_NETWORK_ITEMS=$(getJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"sk_ad_network_items\")\n if [[ $_SK_AD_NETWORK_ITEMS ]]; then\n _PLIST_ENTRY_KEYS+=(\"SKAdNetworkItems\")\n _PLIST_ENTRY_TYPES+=(\"array\")\n _PLIST_ENTRY_VALUES+=(\"\")\n\n oldifs=$IFS\n IFS=\"\n\"\n array=($(echo \"$_SK_AD_NETWORK_ITEMS\"))\n IFS=$oldifs\n for i in \"${!array[@]}\"; do\n _PLIST_ENTRY_KEYS+=(\"SKAdNetworkItems:$i:SKAdNetworkIdentifier\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"${array[i]}\") \n done\n fi\n\n # config.user_tracking_usage_description\n _USER_TRACKING_USAGE_DESCRIPTION=$(getJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"user_tracking_usage_description\")\n if [[ $_USER_TRACKING_USAGE_DESCRIPTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"NSUserTrackingUsageDescription\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_USER_TRACKING_USAGE_DESCRIPTION\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"google_mobile_ads_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A ${_JSON_FILE_NAME} file was not found, whilst this file is optional it is recommended to include it to auto-configure services.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nif ! [[ -f \"${_TARGET_PLIST}\" ]]; then\n echo \"error: unable to locate Info.plist to set properties. App will crash without GADApplicationIdentifier set.\"\n exit 1\nfi\n\nif ! [[ $_IOS_APP_ID ]]; then\n echo \"warning: ios_app_id key not found in react-native-google-mobile-ads key in app.json. App will crash without it.\"\n echo \" You can safely ignore this warning if you are using our Expo config plugin.\"\n exit 0\nfi\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- ${_PROJECT_ABBREVIATION} build script finished\"\n"; @@ -416,8 +410,6 @@ "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", ); name = "[CP-User] [RNFB] Core Configuration"; - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"info: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"info: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"info: -> RNFB build script started\"\necho \"info: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"info: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"info: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\" || echo '')\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACTIVATED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_DEACTIVATED\")\")\n fi\n\n # config.analytics_idfv_collection_enabled\n _ANALYTICS_IDFV_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_idfv_collection_enabled\")\n if [[ $_ANALYTICS_IDFV_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_IDFV_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_IDFV_COLLECTION\")\")\n fi\n\n # config.analytics_default_allow_analytics_storage\n _ANALYTICS_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_analytics_storage\")\n if [[ $_ANALYTICS_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_ANALYTICS_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_storage\n _ANALYTICS_AD_STORAGE=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_storage\")\n if [[ $_ANALYTICS_AD_STORAGE ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_STORAGE\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_STORAGE\")\")\n fi\n\n # config.analytics_default_allow_ad_user_data\n _ANALYTICS_AD_USER_DATA=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_user_data\")\n if [[ $_ANALYTICS_AD_USER_DATA ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_USER_DATA\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AD_USER_DATA\")\")\n fi\n\n # config.analytics_default_allow_ad_personalization_signals\n _ANALYTICS_PERSONALIZATION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_default_allow_ad_personalization_signals\")\n if [[ $_ANALYTICS_PERSONALIZATION ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_DEFAULT_ALLOW_AD_PERSONALIZATION_SIGNALS\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_PERSONALIZATION\")\")\n fi\n\n # config.analytics_registration_with_ad_network_enabled\n _ANALYTICS_REGISTRATION_WITH_AD_NETWORK=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_registration_with_ad_network_enabled\")\n if [[ $_ANALYTICS_REGISTRATION_WITH_AD_NETWORK ]]; then\n _PLIST_ENTRY_KEYS+=(\"GOOGLE_ANALYTICS_REGISTRATION_WITH_AD_NETWORK_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_REGISTRATION_WITH_AD_NETWORK\")\")\n fi\n\n # config.google_analytics_automatic_screen_reporting_enabled\n _ANALYTICS_AUTO_SCREEN_REPORTING=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"google_analytics_automatic_screen_reporting_enabled\")\n if [[ $_ANALYTICS_AUTO_SCREEN_REPORTING ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAutomaticScreenReportingEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_SCREEN_REPORTING\")\")\n fi\n\n # config.perf_auto_collection_enabled\n _PERF_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_auto_collection_enabled\")\n if [[ $_PERF_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_enabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_AUTO_COLLECTION\")\")\n fi\n\n # config.perf_collection_deactivated\n _PERF_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"perf_collection_deactivated\")\n if [[ $_PERF_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"firebase_performance_collection_deactivated\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_PERF_DEACTIVATED\")\")\n fi\n\n # config.messaging_auto_init_enabled\n _MESSAGING_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"messaging_auto_init_enabled\")\n if [[ $_MESSAGING_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseMessagingAutoInitEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_MESSAGING_AUTO_INIT\")\")\n fi\n\n # config.in_app_messaging_auto_colllection_enabled\n _FIAM_AUTO_INIT=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"in_app_messaging_auto_collection_enabled\")\n if [[ $_FIAM_AUTO_INIT ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseInAppMessagingAutomaticDataCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_FIAM_AUTO_INIT\")\")\n fi\n\n # config.app_check_token_auto_refresh\n _APP_CHECK_TOKEN_AUTO_REFRESH=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_check_token_auto_refresh\")\n if [[ $_APP_CHECK_TOKEN_AUTO_REFRESH ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseAppCheckTokenAutoRefreshEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_CHECK_TOKEN_AUTO_REFRESH\")\")\n fi\n\n # config.crashlytics_disable_auto_disabler - undocumented for now - mainly for debugging, document if becomes useful\n _CRASHLYTICS_AUTO_DISABLE_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"crashlytics_disable_auto_disabler\")\n if [[ $_CRASHLYTICS_AUTO_DISABLE_ENABLED == \"true\" ]]; then\n echo \"Disabled Crashlytics auto disabler.\" # do nothing\n else\n _PLIST_ENTRY_KEYS+=(\"FirebaseCrashlyticsCollectionEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"NO\")\n fi\nelse\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n echo \"warning: A firebase.json file was not found, whilst this file is optional it is recommended to include it to configure firebase services in React Native Firebase.\"\nfi;\n\necho \"info: 2) Injecting Info.plist entries: \"\n\n# Log out the keys we're adding\nfor i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n echo \" -> $i) ${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\"\ndone\n\nfor plist in \"${_TARGET_PLIST}\" \"${_DSYM_PLIST}\" ; do\n if [[ -f \"${plist}\" ]]; then\n\n # paths with spaces break the call to setPlistValue. temporarily modify\n # the shell internal field separator variable (IFS), which normally\n # includes spaces, to consist only of line breaks\n oldifs=$IFS\n IFS=\"\n\"\n\n for i in \"${!_PLIST_ENTRY_KEYS[@]}\"; do\n setPlistValue \"${_PLIST_ENTRY_KEYS[$i]}\" \"${_PLIST_ENTRY_TYPES[$i]}\" \"${_PLIST_ENTRY_VALUES[$i]}\" \"${plist}\"\n done\n\n # restore the original internal field separator value\n IFS=$oldifs\n else\n echo \"warning: A Info.plist build output file was not found (${plist})\"\n fi\ndone\n\necho \"info: <- RNFB build script finished\"\n"; @@ -474,14 +466,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-frameworks.sh\"\n"; @@ -497,8 +485,6 @@ "$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)", ); name = "[CP-User] [RNFB] Crashlytics Configuration"; - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\nset -e\n\nif [[ ${PODS_ROOT} ]]; then\n echo \"info: Exec FirebaseCrashlytics Run from Pods\"\n \"${PODS_ROOT}/FirebaseCrashlytics/run\"\nelse\n echo \"info: Exec FirebaseCrashlytics Run from framework\"\n \"${PROJECT_DIR}/FirebaseCrashlytics.framework/run\"\nfi\n"; @@ -511,14 +497,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InkNest/Pods-InkNest-resources.sh\"\n"; @@ -532,14 +514,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-InkNest-InkNestTests/Pods-InkNest-InkNestTests-resources.sh\"\n"; @@ -774,7 +752,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG"; @@ -854,7 +835,10 @@ "-DFOLLY_CFG_NO_COROUTINES=1", "-DFOLLY_HAVE_CLOCK_GETTIME=1", ); - OTHER_LDFLAGS = "$(inherited) "; + OTHER_LDFLAGS = ( + "$(inherited)", + " ", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; USE_HERMES = true; diff --git a/ios/Podfile b/ios/Podfile index ace878eb..9c8e5616 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -64,5 +64,20 @@ target 'InkNest' do :mac_catalyst_enabled => false, # :ccache_enabled => true ) + + # Patch fmt base.h for Apple Clang 21+ (Xcode 26) consteval compatibility. + # Apple Clang 21 (__apple_build_version__ >= 21000000) has stricter consteval + # enforcement that breaks fmt 11.x. fmt only excludes < 14000029 (Clang < 14). + fmt_base_h = File.join(installer.sandbox.root, 'fmt/include/fmt/base.h') + if File.exist?(fmt_base_h) + content = File.read(fmt_base_h) + old_check = '#elif defined(__apple_build_version__) && __apple_build_version__ < 14000029L' + new_check = '#elif defined(__apple_build_version__) && (__apple_build_version__ < 14000029L || __apple_build_version__ >= 21000000L)' + if content.include?(old_check) && !content.include?(new_check) + File.chmod(0644, fmt_base_h) + File.write(fmt_base_h, content.gsub(old_check, new_check)) + puts '[Fix] Patched fmt/base.h for Apple Clang 21+ consteval compatibility' + end + end end end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index cdd39eef..1ac0e517 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -4152,6 +4152,6 @@ SPEC CHECKSUMS: UnrarKit: 62f535c7a34ec52d2514b9b148f33dcfa9a9dc39 Yoga: 1259c7a8cbaccf7b4c3ddf8ee36ca11be9dee407 -PODFILE CHECKSUM: 508b472b779be51eb204147483558e3ddf0e43ea +PODFILE CHECKSUM: b45710cb91af671d189404d2388ad71bac78490a -COCOAPODS: 1.15.2 +COCOAPODS: 1.16.2 diff --git a/src/Components/Func/HomeFunc.js b/src/Components/Func/HomeFunc.js index 51f09b59..480b3729 100644 --- a/src/Components/Func/HomeFunc.js +++ b/src/Components/Func/HomeFunc.js @@ -77,12 +77,12 @@ export const fetchComicsData = async (link, dispatch, baseUrl) => { .replaceAll(',', ''); } } - dispatch(checkDownTime(response)); + dispatch(checkDownTime(response, baseUrl)); return {data: comicsData, lastPage}; } catch (error) { // console.log(link, 'link'); console.log('Error fetching or parsing data Home:', error); - if (dispatch) dispatch(checkDownTime(error)); + if (dispatch) dispatch(checkDownTime(error, baseUrl)); return []; } }; @@ -155,27 +155,27 @@ export const FetchAnimeData = async (link, dispatch, baseUrl) => { // console.log(AnimaData, 'videos'); } - dispatch(checkDownTime(response)); + dispatch(checkDownTime(response, baseUrl)); return AnimaData; } catch (error) { console.log('Error fetching or parsing data Anime Home page: ', error); - if (dispatch) dispatch(checkDownTime(error)); + if (dispatch) dispatch(checkDownTime(error, baseUrl)); return []; } }; -export const checkServerDown = async (url, dispatch) => { +export const checkServerDown = async (url, dispatch, sourceKey = null) => { dispatch(fetchDataStart()); try { const response = await APICaller.get(url); //set DownTime to false console.log(response.status, 'response'); - dispatch(checkDownTime(response)); + dispatch(checkDownTime(response, sourceKey)); return false; } catch (error) { //set DownTime to true console.log('Error checking server down:', error.message); - dispatch(checkDownTime(error)); + dispatch(checkDownTime(error, sourceKey)); return true; } }; diff --git a/src/Components/UIComp/DownTime.js b/src/Components/UIComp/DownTime.js index 65f7ce64..9c3c4961 100644 --- a/src/Components/UIComp/DownTime.js +++ b/src/Components/UIComp/DownTime.js @@ -22,9 +22,9 @@ const DownTime = () => { title="Retry" onPress={() => { if (animeActive) { - checkServerDown(AnimeHostName[baseUrl], dispatch) + checkServerDown(AnimeHostName[baseUrl], dispatch, baseUrl) } else { - checkServerDown(ComicHostName[baseUrl], dispatch) + checkServerDown(ComicHostName[baseUrl], dispatch, baseUrl) } }} textSize={20} diff --git a/src/Components/UIComp/SourceStatusBanner.js b/src/Components/UIComp/SourceStatusBanner.js new file mode 100644 index 00000000..8267a7fd --- /dev/null +++ b/src/Components/UIComp/SourceStatusBanner.js @@ -0,0 +1,283 @@ +import React from 'react'; +import {View, Text, StyleSheet, TouchableOpacity} from 'react-native'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; +import {SOURCE_STATUS, formatLastChecked} from '../../Utils/sourceStatus'; + +/** + * Inline banner component to show source status errors + * Use this for immediate feedback when a source returns 403 or 500+ + */ +const SourceStatusBanner = ({ + sourceKey, + sourceName, + status, + statusCode, + lastChecked, + onDismiss, + onSwitchSource, + showActions = true, +}) => { + if (!status || status === SOURCE_STATUS.WORKING) { + return null; + } + + const getStatusConfig = () => { + switch (status) { + case SOURCE_STATUS.CLOUDFLARE_PROTECTED: + return { + icon: 'shield-alert', + color: '#FF9500', + backgroundColor: 'rgba(255, 149, 0, 0.15)', + title: 'Cloudflare Protection', + message: `${sourceName || sourceKey} is blocking requests due to cloudflare bot protection.`, + }; + case SOURCE_STATUS.SERVER_DOWN: + return { + icon: 'server-off', + color: '#FF3B30', + backgroundColor: 'rgba(255, 59, 48, 0.15)', + title: 'Server Down', + message: `${sourceName || sourceKey} server is not responding.`, + }; + default: + return { + icon: 'alert-circle', + color: '#8E8E93', + backgroundColor: 'rgba(142, 142, 147, 0.15)', + title: 'Source Error', + message: `${sourceName || sourceKey} is experiencing issues.`, + }; + } + }; + + const config = getStatusConfig(); + + return ( + + + + + + + + + {config.title} + + {statusCode && ( + ({statusCode}) + )} + + {config.message} + {lastChecked && ( + + Last checked: {formatLastChecked(lastChecked)} + + )} + + + {showActions && ( + + {onSwitchSource && ( + + + + )} + {onDismiss && ( + + + + )} + + )} + + ); +}; + +/** + * Compact version for use in headers or smaller spaces + */ +export const SourceStatusBadge = ({status, onPress}) => { + if (!status || status === SOURCE_STATUS.WORKING) { + return null; + } + + const getColor = () => { + switch (status) { + case SOURCE_STATUS.CLOUDFLARE_PROTECTED: + return '#FF9500'; + case SOURCE_STATUS.SERVER_DOWN: + return '#FF3B30'; + default: + return '#8E8E93'; + } + }; + + return ( + + + + {status === SOURCE_STATUS.CLOUDFLARE_PROTECTED ? '403' : 'Down'} + + + ); +}; + +/** + * Toast-style notification for source errors + */ +export const SourceStatusToast = ({ + visible, + sourceName, + status, + onDismiss, + autoHide = true, + duration = 5000, +}) => { + React.useEffect(() => { + if (visible && autoHide && onDismiss) { + const timer = setTimeout(onDismiss, duration); + return () => clearTimeout(timer); + } + }, [visible, autoHide, duration, onDismiss]); + + if (!visible) { + return null; + } + + const getMessage = () => { + switch (status) { + case SOURCE_STATUS.CLOUDFLARE_PROTECTED: + return `${sourceName}: Cloudflare protection active (403)`; + case SOURCE_STATUS.SERVER_DOWN: + return `${sourceName}: Server is down`; + default: + return `${sourceName}: Error`; + } + }; + + const getColor = () => { + switch (status) { + case SOURCE_STATUS.CLOUDFLARE_PROTECTED: + return '#FF9500'; + case SOURCE_STATUS.SERVER_DOWN: + return '#FF3B30'; + default: + return '#8E8E93'; + } + }; + + return ( + + + {getMessage()} + {onDismiss && ( + + + + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + padding: 12, + borderRadius: 8, + marginHorizontal: 16, + marginVertical: 8, + }, + iconContainer: { + marginRight: 12, + }, + contentContainer: { + flex: 1, + }, + titleRow: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 2, + }, + title: { + fontSize: 14, + fontWeight: '600', + }, + errorCode: { + fontSize: 12, + color: '#8E8E93', + marginLeft: 6, + }, + message: { + fontSize: 13, + color: '#ABABAB', + lineHeight: 18, + }, + lastChecked: { + fontSize: 11, + color: '#636366', + marginTop: 4, + }, + actionsContainer: { + flexDirection: 'row', + alignItems: 'center', + marginLeft: 8, + }, + switchButton: { + padding: 8, + marginRight: 4, + }, + dismissButton: { + padding: 8, + }, + // Badge styles + badgeContainer: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + borderWidth: 1, + gap: 4, + }, + badgeText: { + fontSize: 11, + fontWeight: '600', + }, + // Toast styles + toastContainer: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#2C2C2E', + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + marginHorizontal: 16, + borderLeftWidth: 4, + shadowColor: '#000', + shadowOffset: {width: 0, height: 2}, + shadowOpacity: 0.25, + shadowRadius: 4, + elevation: 5, + }, + toastMessage: { + flex: 1, + fontSize: 14, + color: '#FFFFFF', + marginLeft: 12, + }, + toastDismiss: { + padding: 4, + marginLeft: 8, + }, +}); + +export default SourceStatusBanner; \ No newline at end of file diff --git a/src/Components/UIComp/SourceStatusNotification.js b/src/Components/UIComp/SourceStatusNotification.js new file mode 100644 index 00000000..b8298448 --- /dev/null +++ b/src/Components/UIComp/SourceStatusNotification.js @@ -0,0 +1,287 @@ +import React from 'react'; +import {View, Text, StyleSheet, TouchableOpacity, ScrollView} from 'react-native'; +import {useSelector, useDispatch} from 'react-redux'; +import { + markSourceStatusNotificationRead, + removeSourceStatusNotification, + clearSourceStatusNotifications, +} from '../../Redux/Reducers'; +import {SOURCE_STATUS, formatLastChecked} from '../../Utils/sourceStatus'; +import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; + +/** + * Component to display source status notifications + * Shows which sources have 403 (Cloudflare) or 500+ (server down) errors + */ +const SourceStatusNotification = ({onClose}) => { + const dispatch = useDispatch(); + const notifications = useSelector( + state => state.data.sourceStatusNotifications || [], + ); + + const unreadCount = notifications.filter(n => !n.read).length; + + if (notifications.length === 0) { + return null; + } + + const handleMarkAsRead = notificationId => { + dispatch(markSourceStatusNotificationRead(notificationId)); + }; + + const handleDismiss = notificationId => { + dispatch(removeSourceStatusNotification(notificationId)); + }; + + const handleClearAll = () => { + dispatch(clearSourceStatusNotifications()); + if (onClose) { + onClose(); + } + }; + + const getStatusIcon = status => { + switch (status) { + case SOURCE_STATUS.CLOUDFLARE_PROTECTED: + return 'shield-alert'; + case SOURCE_STATUS.SERVER_DOWN: + return 'server-off'; + default: + return 'alert-circle'; + } + }; + + const getStatusColor = status => { + switch (status) { + case SOURCE_STATUS.CLOUDFLARE_PROTECTED: + return '#FF9500'; // Orange for Cloudflare + case SOURCE_STATUS.SERVER_DOWN: + return '#FF3B30'; // Red for server down + default: + return '#8E8E93'; + } + }; + + const getStatusTitle = status => { + switch (status) { + case SOURCE_STATUS.CLOUDFLARE_PROTECTED: + return 'Cloudflare Protection Active'; + case SOURCE_STATUS.SERVER_DOWN: + return 'Server Down'; + default: + return 'Source Error'; + } + }; + + const getStatusDescription = (status, sourceName) => { + switch (status) { + case SOURCE_STATUS.CLOUDFLARE_PROTECTED: + return `${sourceName} is blocking requests due to cloudflare bot protection. Try switching to another source.`; + case SOURCE_STATUS.SERVER_DOWN: + return `${sourceName} server is not responding. The source may be temporarily unavailable.`; + default: + return `${sourceName} is experiencing issues.`; + } + }; + + return ( + + + + + Source Status + {unreadCount > 0 && ( + + {unreadCount} + + )} + + + Clear All + + + + + {notifications.map(notification => ( + handleMarkAsRead(notification.id)} + activeOpacity={0.7}> + + + + + + + + {notification.sourceName || notification.sourceKey} + + + {getStatusTitle(notification.status)} + + + + + {getStatusDescription( + notification.status, + notification.sourceName || notification.sourceKey, + )} + + + + + Last checked: {formatLastChecked(notification.timestamp)} + + {notification.statusCode && ( + + Error: {notification.statusCode} + + )} + + + + handleDismiss(notification.id)}> + + + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + backgroundColor: '#1C1C1E', + borderRadius: 12, + marginHorizontal: 16, + marginVertical: 8, + overflow: 'hidden', + maxHeight: 400, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#38383A', + }, + headerLeft: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + headerTitle: { + fontSize: 16, + fontWeight: '600', + color: '#FFFFFF', + }, + badge: { + backgroundColor: '#FF3B30', + borderRadius: 10, + paddingHorizontal: 6, + paddingVertical: 2, + minWidth: 20, + alignItems: 'center', + }, + badgeText: { + color: '#FFFFFF', + fontSize: 12, + fontWeight: '600', + }, + clearButton: { + paddingHorizontal: 12, + paddingVertical: 6, + }, + clearButtonText: { + color: '#0A84FF', + fontSize: 14, + fontWeight: '500', + }, + list: { + maxHeight: 320, + }, + notificationItem: { + flexDirection: 'row', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#38383A', + }, + unreadItem: { + backgroundColor: 'rgba(10, 132, 255, 0.1)', + }, + iconContainer: { + width: 44, + height: 44, + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + contentContainer: { + flex: 1, + }, + titleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 4, + }, + sourceName: { + fontSize: 15, + fontWeight: '600', + color: '#FFFFFF', + flex: 1, + }, + statusText: { + fontSize: 12, + fontWeight: '500', + color: '#8E8E93', + marginLeft: 8, + }, + description: { + fontSize: 13, + color: '#ABABAB', + lineHeight: 18, + marginBottom: 8, + }, + footer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + timestamp: { + fontSize: 12, + color: '#636366', + }, + errorCode: { + fontSize: 12, + color: '#FF3B30', + fontWeight: '500', + }, + dismissButton: { + padding: 8, + marginLeft: 8, + }, +}); + +export default SourceStatusNotification; \ No newline at end of file diff --git a/src/Constants/Navigation.js b/src/Constants/Navigation.js index 042b8fc6..3b6be7de 100644 --- a/src/Constants/Navigation.js +++ b/src/Constants/Navigation.js @@ -30,4 +30,11 @@ export const NAVIGATION = { Library: 'Library', CreatePost: 'CreatePost', PostDetail: 'PostDetail', + // Novel Navigation + novelHome: 'NovelHome', + novelDetails: 'NovelDetails', + novelReader: 'NovelReader', + novelSearch: 'NovelSearch', + novelLibrary: 'NovelLibrary', + novelViewAll: 'NovelViewAll', }; diff --git a/src/InkNest-Externals b/src/InkNest-Externals index fa5206a1..c90708aa 160000 --- a/src/InkNest-Externals +++ b/src/InkNest-Externals @@ -1 +1 @@ -Subproject commit fa5206a1bb7201f62dca39f9c37c4be3c5f9c245 +Subproject commit c90708aa5576720648ecfb753d738867d7e4d236 diff --git a/src/Navigation/AppNavigation.js b/src/Navigation/AppNavigation.js index 39b2da11..c4a9861b 100644 --- a/src/Navigation/AppNavigation.js +++ b/src/Navigation/AppNavigation.js @@ -25,6 +25,14 @@ import { import WebViewScreen from '../InkNest-Externals/Screens/Webview/WebViewScreen'; import WebSearchScreen from '../InkNest-Externals/Screens/Webview/WebSearchScreen'; import NotificationsScreen from '../Screens/Notifications/NotificationsScreen'; +import { + NovelHome, + NovelDetails, + NovelReader, + NovelSearch, + NovelLibrary, + NovelViewAll, +} from '../Screens/Novel'; const Stack = createNativeStackNavigator(); @@ -79,6 +87,13 @@ export function AppNavigation() { name={NAVIGATION.notifications} component={NotificationsScreen} /> + {/* Novel Screens */} + + + + + + ); } diff --git a/src/Navigation/index.js b/src/Navigation/index.js index f1267a86..217751c7 100644 --- a/src/Navigation/index.js +++ b/src/Navigation/index.js @@ -240,7 +240,7 @@ export function RootNavigation() { // We still persist to storage for background/offline safety, // but we don't rely on it as the source of truth for the UI anymore. // The 'consumeStoredNotifications' will pick this up if the app restarts. - persistNotificationList(deduped).catch(() => { }); + persistNotificationList(deduped); dispatch(appendNotification(parsedPayload)); diff --git a/src/Redux/Actions/GlobalActions.js b/src/Redux/Actions/GlobalActions.js index 356db3dd..8032f4e7 100644 --- a/src/Redux/Actions/GlobalActions.js +++ b/src/Redux/Actions/GlobalActions.js @@ -133,7 +133,8 @@ export const fetchComicDetails = dispatch(WatchedData(watchedData)); dispatch(fetchDataSuccess({url: link, data: comicDetails})); } catch (error) { - handleAPIError(error, dispatch, crashlytics, 'fetchComicDetails'); + const hostkey = getHostKeyFromUrl(link, ComicDetailPageClasses); + handleAPIError(error, dispatch, crashlytics, 'fetchComicDetails', hostkey); dispatch(ClearError()); dispatch(fetchDataFailure('Not Found')); goBack(); @@ -223,7 +224,7 @@ export const fetchComicBook = return {url: originalComicBook, data}; } } catch (error) { - handleAPIError(error, dispatch, crashlytics, 'fetchComicBook'); + handleAPIError(error, dispatch, crashlytics, 'fetchComicBook', hostkey); dispatch(fetchDataFailure(error.message)); } }; @@ -282,11 +283,11 @@ export const getAdvancedSearchFilters = (source = 'readcomicsonline') => async d const filters = parseAdvancedSearchFilters($, config); - dispatch(checkDownTime({filters})); + dispatch(checkDownTime({filters}, source)); return filters; } catch (error) { - handleAPIError(error, dispatch, crashlytics, 'getAdvancedSearchFilters'); + handleAPIError(error, dispatch, crashlytics, 'getAdvancedSearchFilters', source); dispatch(fetchDataFailure(error.message)); return null; } @@ -320,10 +321,10 @@ export const searchComic = dispatch(fetchDataSuccess({url, data: formatted})); dispatch(StopLoading()); dispatch(ClearError()); - dispatch(checkDownTime()); + dispatch(checkDownTime(null, source)); return formatted; } catch (error) { - handleAPIError(error, dispatch, crashlytics, 'searchComic'); + handleAPIError(error, dispatch, crashlytics, 'searchComic', source); dispatch(fetchDataFailure(error.message)); return null; } diff --git a/src/Redux/Actions/NovelActions.js b/src/Redux/Actions/NovelActions.js new file mode 100644 index 00000000..3a58f4af --- /dev/null +++ b/src/Redux/Actions/NovelActions.js @@ -0,0 +1,263 @@ +import {Alert} from 'react-native'; +import crashlytics from '@react-native-firebase/crashlytics'; + +// Redux actions +import { + fetchDataStart, + fetchDataSuccess, + fetchDataFailure, + clearData, + StopLoading, + ClearError, + pushHistory, + updateData, +} from '../Reducers'; + +// Navigation +import {goBack} from '../../Navigation/NavigationService'; + +// Novel APIs +import { + getNovelHome, + getNovelDetails, + getNovelChapter, + getNovelHostKeyFromLink, + NovelHomePageClasses, + NovelDetailPageClasses, + NovelChapterPageClasses, +} from '../../Screens/Novel/APIs'; + +// Utilities +import {checkDownTime, handleAPIError} from './utils/errorHandlers'; +import {hasLoadedChapterData, buildWatchedData} from './utils/dataHelpers'; + +// Re-export checkDownTime for backward compatibility +export {checkDownTime}; + +// ============================================ +// Action Type Constants +// ============================================ + +export const NOVEL_ACTION_TYPES = { + SWITCH_NOVEL_SOURCE: 'SWITCH_NOVEL_SOURCE', + FETCH_NOVEL_HOME_START: 'FETCH_NOVEL_HOME_START', + FETCH_NOVEL_HOME_SUCCESS: 'FETCH_NOVEL_HOME_SUCCESS', + FETCH_NOVEL_HOME_FAILURE: 'FETCH_NOVEL_HOME_FAILURE', + FETCH_NOVEL_DETAILS_START: 'FETCH_NOVEL_DETAILS_START', + FETCH_NOVEL_DETAILS_SUCCESS: 'FETCH_NOVEL_DETAILS_SUCCESS', + FETCH_NOVEL_DETAILS_FAILURE: 'FETCH_NOVEL_DETAILS_FAILURE', + FETCH_NOVEL_CHAPTER_START: 'FETCH_NOVEL_CHAPTER_START', + FETCH_NOVEL_CHAPTER_SUCCESS: 'FETCH_NOVEL_CHAPTER_SUCCESS', + FETCH_NOVEL_CHAPTER_FAILURE: 'FETCH_NOVEL_CHAPTER_FAILURE', + CLEAR_NOVEL_DATA: 'CLEAR_NOVEL_DATA', +}; + +// ============================================ +// Action Creators +// ============================================ + +/** + * Action creator to switch the active novel source. + * + * @param {string} sourceKey - The key identifying the novel source (e.g., 'novelfire'). + * @returns {Object} The action object with type and payload. + */ +export const switchNovelSource = sourceKey => ({ + type: NOVEL_ACTION_TYPES.SWITCH_NOVEL_SOURCE, + payload: sourceKey, +}); + +/** + * Action creator for handling watched data. + * + * @param {Object} data - The data to be processed and dispatched. + * @returns {Function} A thunk function that dispatches the pushHistory action. + */ +export const WatchedData = data => async (dispatch, getState) => { + dispatch(pushHistory(data)); +}; + +// ============================================ +// Thunk Actions +// ============================================ + +/** + * Fetches novel home data for a given source and dispatches appropriate actions. + * + * @param {string} [hostKey='novelfire'] - The host key identifying the novel source. + * @returns {Function} A thunk function that performs the async operation. + */ +export const fetchNovelHome = + (hostKey = 'novelfire') => + async (dispatch, getState) => { + dispatch(fetchDataStart()); + dispatch({type: NOVEL_ACTION_TYPES.FETCH_NOVEL_HOME_START}); + + try { + const homeData = await getNovelHome(hostKey); + + dispatch(StopLoading()); + dispatch(ClearError()); + dispatch(checkDownTime()); + dispatch({ + type: NOVEL_ACTION_TYPES.FETCH_NOVEL_HOME_SUCCESS, + payload: {hostKey, data: homeData}, + }); + } catch (error) { + handleAPIError(error, dispatch, crashlytics, 'fetchNovelHome', hostKey); + dispatch(ClearError()); + dispatch({ + type: NOVEL_ACTION_TYPES.FETCH_NOVEL_HOME_FAILURE, + payload: error.message || 'Failed to fetch novel home', + }); + Alert.alert('Error', 'Failed to load novel home page'); + } + }; + +/** + * Fetches novel details from a given link and dispatches appropriate actions. + * + * @param {string} link - The URL of the novel to fetch details for. + * @param {string} [hostKey] - Optional host key. If not provided, will be extracted from link. + * @param {boolean} [refresh=false] - Whether to refresh the data even if it exists in the state. + * @returns {Function} A thunk function that performs the async operation. + */ +export const fetchNovelDetails = + (link, hostKey, refresh = false) => + async (dispatch, getState) => { + dispatch(fetchDataStart()); + dispatch({type: NOVEL_ACTION_TYPES.FETCH_NOVEL_DETAILS_START}); + + try { + // Determine host key from link if not provided + const resolvedHostKey = + hostKey || getNovelHostKeyFromLink(link, NovelDetailPageClasses); + + // Check if we can use cached data + const state = getState(); + const stateData = state?.data?.dataByUrl?.[link]; + const hasUsableStateData = hasLoadedChapterData(stateData); + let watchedData = buildWatchedData(stateData, link); + + if (!refresh && stateData && hasUsableStateData) { + dispatch(StopLoading()); + dispatch(ClearError()); + dispatch(checkDownTime()); + dispatch(WatchedData(watchedData)); + return; + } + + // Fetch fresh data + const novelDetails = await getNovelDetails(link, resolvedHostKey); + watchedData = buildWatchedData(novelDetails, link); + + // Update or save data based on refresh flag + if (refresh) { + dispatch(updateData({url: link, data: novelDetails})); + dispatch(StopLoading()); + dispatch(WatchedData(watchedData)); + return; + } + + dispatch(WatchedData(watchedData)); + dispatch(fetchDataSuccess({url: link, data: novelDetails})); + dispatch({ + type: NOVEL_ACTION_TYPES.FETCH_NOVEL_DETAILS_SUCCESS, + payload: {link, data: novelDetails, hostKey: resolvedHostKey}, + }); + } catch (error) { + const resolvedHostKey = + hostKey || getNovelHostKeyFromLink(link, NovelDetailPageClasses); + handleAPIError( + error, + dispatch, + crashlytics, + 'fetchNovelDetails', + resolvedHostKey, + ); + dispatch(ClearError()); + dispatch(fetchDataFailure('Not Found')); + dispatch({ + type: NOVEL_ACTION_TYPES.FETCH_NOVEL_DETAILS_FAILURE, + payload: error.message || 'Failed to fetch novel details', + }); + goBack(); + Alert.alert('Error', 'Novel not found'); + } + }; + +/** + * Fetches novel chapter content from a given link and dispatches appropriate actions. + * + * @param {string} link - The URL of the chapter to fetch. + * @param {string} [hostKey] - Optional host key. If not provided, will be extracted from link. + * @param {boolean} [refresh=false] - Whether to refresh the data even if it exists in the state. + * @returns {Function} A thunk function that performs the async operation. + */ +export const fetchNovelChapter = + (link, hostKey, refresh = false) => + async (dispatch, getState) => { + dispatch(fetchDataStart()); + dispatch({type: NOVEL_ACTION_TYPES.FETCH_NOVEL_CHAPTER_START}); + + try { + // Determine host key from link if not provided + const resolvedHostKey = + hostKey || getNovelHostKeyFromLink(link, NovelChapterPageClasses); + + // Check if we can use cached data + const state = getState(); + const stateData = state?.data?.dataByUrl?.[link]; + + if (!refresh && stateData && stateData.content) { + dispatch(StopLoading()); + dispatch(ClearError()); + dispatch(checkDownTime()); + return; + } + + // Fetch fresh data + const chapterData = await getNovelChapter(link, resolvedHostKey); + + // Update or save data based on refresh flag + if (refresh) { + dispatch(updateData({url: link, data: chapterData})); + dispatch(StopLoading()); + return; + } + + dispatch(fetchDataSuccess({url: link, data: chapterData})); + dispatch({ + type: NOVEL_ACTION_TYPES.FETCH_NOVEL_CHAPTER_SUCCESS, + payload: {link, data: chapterData, hostKey: resolvedHostKey}, + }); + } catch (error) { + const resolvedHostKey = + hostKey || getNovelHostKeyFromLink(link, NovelChapterPageClasses); + handleAPIError( + error, + dispatch, + crashlytics, + 'fetchNovelChapter', + resolvedHostKey, + ); + dispatch(ClearError()); + dispatch(fetchDataFailure('Not Found')); + dispatch({ + type: NOVEL_ACTION_TYPES.FETCH_NOVEL_CHAPTER_FAILURE, + payload: error.message || 'Failed to fetch chapter', + }); + goBack(); + Alert.alert('Error', 'Chapter not found'); + } + }; + +/** + * Clears all novel-related data from the store. + * + * @returns {Function} A thunk function that dispatches the clear action. + */ +export const clearNovelData = () => dispatch => { + dispatch(clearData()); + dispatch({type: NOVEL_ACTION_TYPES.CLEAR_NOVEL_DATA}); +}; \ No newline at end of file diff --git a/src/Redux/Actions/parsers/novelChapterParser.js b/src/Redux/Actions/parsers/novelChapterParser.js new file mode 100644 index 00000000..72ceecd7 --- /dev/null +++ b/src/Redux/Actions/parsers/novelChapterParser.js @@ -0,0 +1,1377 @@ +/** + * Novel chapter content parsing utilities + * Generic parser for extracting chapter content from various novel sources + */ + +/** + * Cleans HTML entities and normalizes text + * + * @param {string} text - Text to clean + * @returns {string} - Cleaned text + */ +const cleanText = (text) => { + if (!text) { + return ''; + } + return text + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); +}; + +/** + * Base64 decode helper for React Native environments + * @param {string} str - Base64 encoded string + * @returns {string} - Decoded string + */ +const base64Decode = (str) => { + try { + // Try using the global atob if available + if (typeof atob !== 'undefined') { + return atob(str); + } + // Manual base64 decode as fallback + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; + let output = ''; + let i = 0; + str = str.replace(/[^A-Za-z0-9\+\/\=]/g, ''); + while (i < str.length) { + const enc1 = chars.indexOf(str.charAt(i++)); + const enc2 = chars.indexOf(str.charAt(i++)); + const enc3 = chars.indexOf(str.charAt(i++)); + const enc4 = chars.indexOf(str.charAt(i++)); + const chr1 = (enc1 << 2) | (enc2 >> 4); + const chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); + const chr3 = ((enc3 & 3) << 6) | enc4; + output += String.fromCharCode(chr1); + if (enc3 !== 64) output += String.fromCharCode(chr2); + if (enc4 !== 64) output += String.fromCharCode(chr3); + } + // Handle UTF-8 encoding + return decodeURIComponent(escape(output)); + } catch (e) { + console.log('[base64Decode] Failed to decode:', e.message); + return str; + } +}; + +/** + * Convert base64 string to Uint8Array + * @param {string} base64 - Base64 encoded string + * @returns {Uint8Array} - Byte array + */ +const base64ToUint8Array = (base64) => { + const binaryString = base64Decode(base64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return bytes; +}; + +/** + * Decrypt WTR-Lab arr: format content + * Format: arr:iv:authTag:ciphertext (all base64 encoded) + * Uses AES-GCM decryption + * @param {string} encryptedData - The arr: prefixed encrypted string + * @returns {string} - Decrypted content + */ +const decryptWTRLabContent = (encryptedData) => { + try { + if (!encryptedData || !encryptedData.startsWith('arr:')) { + return null; + } + + console.log('[decryptWTRLabContent] Attempting to decrypt arr: format'); + + // Parse the arr: format + const parts = encryptedData.substring(4).split(':'); + console.log('[decryptWTRLabContent] Parts count:', parts.length); + + if (parts.length < 2) { + console.log('[decryptWTRLabContent] Invalid arr: format - not enough parts'); + return null; + } + + // The format appears to be: arr:iv:authTag:ciphertext + // Or possibly: arr:keyId:iv:ciphertext + const ivBase64 = parts[0]; + const authTagOrKeyBase64 = parts[1]; + const ciphertextBase64 = parts.slice(2).join(':'); // In case ciphertext contains colons + + console.log('[decryptWTRLabContent] IV (base64):', ivBase64.substring(0, 20)); + console.log('[decryptWTRLabContent] Auth/Key (base64):', authTagOrKeyBase64.substring(0, 20)); + console.log('[decryptWTRLabContent] Ciphertext (base64) length:', ciphertextBase64.length); + + // Try to decode the parts + const iv = base64ToUint8Array(ivBase64); + const authTagOrKey = base64ToUint8Array(authTagOrKeyBase64); + const ciphertext = base64ToUint8Array(ciphertextBase64); + + console.log('[decryptWTRLabContent] IV bytes:', iv.length); + console.log('[decryptWTRLabContent] Auth/Key bytes:', authTagOrKey.length); + console.log('[decryptWTRLabContent] Ciphertext bytes:', ciphertext.length); + + // Check if Web Crypto API is available + if (typeof crypto !== 'undefined' && crypto.subtle) { + console.log('[decryptWTRLabContent] Web Crypto API available'); + // This would need to be async, so we'll return null for now + // and handle this in a different way + return null; + } + + // Try simple XOR decryption (some sites use simple XOR) + // This is a fallback attempt + console.log('[decryptWTRLabContent] Trying simple decryption methods'); + + // Try decoding each part as UTF-8 directly + for (const part of parts) { + try { + const decoded = base64Decode(part); + if (decoded && decoded.length > 50 && /[a-zA-Z]/.test(decoded)) { + console.log('[decryptWTRLabContent] Found readable content in part:', decoded.substring(0, 100)); + return decoded; + } + } catch (e) { + // Ignore decode errors + } + } + + // Try combining all parts and decoding + const combined = parts.join(''); + try { + const decoded = base64Decode(combined); + if (decoded && decoded.length > 50) { + console.log('[decryptWTRLabContent] Decoded combined parts:', decoded.substring(0, 100)); + return decoded; + } + } catch (e) { + // Ignore + } + + console.log('[decryptWTRLabContent] Could not decrypt - Web Crypto API not available or key unknown'); + return null; + } catch (e) { + console.log('[decryptWTRLabContent] Decryption error:', e.message); + return null; + } +}; + +/** + * Extracts plain text content from a selector + * + * @param {Object} $ - Cheerio instance + * @param {string} contentSelector - CSS selector for content container + * @returns {string} - Extracted plain text + */ +export const extractChapterText = ($, contentSelector) => { + const paragraphs = []; + const container = $(contentSelector); + + if (!container.length) { + return ''; + } + + // Extract all paragraph text + container.find('p').each((_, el) => { + const text = $(el).text().trim(); + if (text && text.length > 0) { + paragraphs.push(cleanText(text)); + } + }); + + // If no paragraphs found, try getting direct text content + if (paragraphs.length === 0) { + const directText = container.text().trim(); + if (directText) { + paragraphs.push(cleanText(directText)); + } + } + + return paragraphs.join('\n\n'); +}; + +/** + * Extracts images from chapter content + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @param {string} config.contentSelector - CSS selector for content container + * @param {string} config.imageSelector - CSS selector for images (default: 'img') + * @param {string} config.imageAttr - Attribute to get image URL (default: 'src') + * @returns {Array} - Array of image objects with src and optional caption + */ +export const extractChapterImages = ($, config) => { + const { + contentSelector, + imageSelector = 'img', + imageAttr = 'src', + } = config; + + const images = []; + const seen = new Set(); + const container = $(contentSelector); + + if (!container.length) { + return images; + } + + container.find(imageSelector).each((_, el) => { + // Try multiple attributes for lazy-loaded images + const src = + $(el).attr(imageAttr)?.trim() || + $(el).attr('data-src')?.trim() || + $(el).attr('data-lazy-src')?.trim() || + $(el).attr('data-original')?.trim(); + + if (src && !seen.has(src)) { + seen.add(src); + + // Get alt text or caption if available + const alt = $(el).attr('alt')?.trim() || ''; + const caption = $(el).next('figcaption, .caption').text().trim() || ''; + + images.push({ + src, + alt, + caption: caption || alt, + }); + } + }); + + return images; +}; + +/** + * Parses chapter content using standard configuration + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @param {string} config.contentSelector - CSS selector for main content + * @param {string} config.titleSelector - CSS selector for chapter title + * @param {string} config.prevSelector - CSS selector for previous chapter link + * @param {string} config.nextSelector - CSS selector for next chapter link + * @param {boolean} config.extractImages - Whether to extract images (default: false) + * @param {string} config.paragraphSelector - CSS selector for paragraphs (default: 'p') + * @returns {Object} - Parsed chapter data + */ +export const parseNovelChapter = ($, config) => { + const { + contentSelector = '#content, .chapter-content, article, .content', + titleSelector = 'h1, h2, h3, h4, .chapter-title', + prevSelector = '.prev, .prevchap, a[rel="prev"]', + nextSelector = '.next, .nextchap, a[rel="next"]', + extractImages = false, + paragraphSelector = 'p', + excludeSelectors = [], + } = config; + + const content = { + title: '', + paragraphs: [], + text: '', + images: [], + prevChapter: null, + nextChapter: null, + }; + + // Extract title + const titleEl = $(titleSelector).first(); + content.title = titleEl.text().trim(); + + // Find content container + const container = $(contentSelector).first(); + + console.log('[parseNovelChapter] contentSelector:', contentSelector); + console.log('[parseNovelChapter] container found:', container.length); + console.log('[parseNovelChapter] title:', content.title); + + if (container.length) { + // Remove excluded elements (ads, navigation, etc.) + if (excludeSelectors.length > 0) { + excludeSelectors.forEach((selector) => { + container.find(selector).remove(); + }); + } + + // Extract paragraphs + container.find(paragraphSelector).each((_, el) => { + const text = $(el).text().trim(); + if (text && text.length > 0) { + content.paragraphs.push(cleanText(text)); + } + }); + + console.log('[parseNovelChapter] paragraphs found:', content.paragraphs.length); + + // Join paragraphs into text + content.text = content.paragraphs.join('\n\n'); + + // Extract images if requested + if (extractImages) { + content.images = extractChapterImages($, { + contentSelector, + imageSelector: 'img', + imageAttr: 'src', + }); + } + } + + // Extract navigation links + const prevEl = $(prevSelector).first(); + const nextEl = $(nextSelector).first(); + + // Check for disabled state + const isPrevDisabled = prevEl.hasClass('disabled') || + prevEl.hasClass('isDisabled') || + prevEl.attr('disabled'); + + const isNextDisabled = nextEl.hasClass('disabled') || + nextEl.hasClass('isDisabled') || + nextEl.attr('disabled'); + + if (!isPrevDisabled) { + const prevHref = prevEl.attr('href'); + if (prevHref && !prevHref.toLowerCase().startsWith('javascript')) { + content.prevChapter = prevHref; + } + } + + if (!isNextDisabled) { + const nextHref = nextEl.attr('href'); + if (nextHref && !nextHref.toLowerCase().startsWith('javascript')) { + content.nextChapter = nextHref; + } + } + + return content; +}; + +/** + * Parses chapter content in paginated format + * Some sites split chapters across multiple pages + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @returns {Object} - Parsed chapter data with pagination info + */ +export const parsePaginatedChapter = ($, config) => { + const { + // contentSelector is available for future use + paginationSelector = '.pagination, .page-nav', + currentPageSelector = '.current, .active', + } = config; + + const baseContent = parseNovelChapter($, { ...config, extractImages: true }); + + // Extract pagination info + const pagination = { + currentPage: 1, + totalPages: 1, + hasNext: false, + hasPrev: false, + pages: [], + }; + + const paginationEl = $(paginationSelector); + + if (paginationEl.length) { + // Find current page + const currentEl = paginationEl.find(currentPageSelector).first(); + if (currentEl.length) { + pagination.currentPage = parseInt(currentEl.text().trim(), 10) || 1; + } + + // Find all page links + paginationEl.find('a').each((_, el) => { + const href = $(el).attr('href'); + const pageNum = parseInt($(el).text().trim(), 10); + if (href && pageNum) { + pagination.pages.push({ number: pageNum, url: href }); + pagination.totalPages = Math.max(pagination.totalPages, pageNum); + } + }); + + // Check for next/prev + pagination.hasNext = pagination.currentPage < pagination.totalPages; + pagination.hasPrev = pagination.currentPage > 1; + } + + return { + ...baseContent, + pagination, + }; +}; + +/** + * Parses chapter content with HTML preserved + * Useful for chapters with complex formatting + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @returns {Object} - Parsed chapter data with HTML content + */ +export const parseChapterWithHtml = ($, config) => { + const { contentSelector = '#content, .chapter-content, article' } = config; + + const baseContent = parseNovelChapter($, config); + + // Get raw HTML content + const container = $(contentSelector).first(); + const htmlContent = container.html() || ''; + + return { + ...baseContent, + html: htmlContent, + }; +}; + +/** + * Creates the chapter data object for storage + * + * @param {Object} parsedData - Parsed data from parser functions + * @param {Object} metadata - Additional metadata (chapter number, link, etc.) + * @returns {Object} - Complete chapter data object + */ +export const createChapterData = (parsedData, metadata = {}) => ({ + title: parsedData.title || '', + content: parsedData.text || '', + paragraphs: parsedData.paragraphs || [], + images: parsedData.images || [], + prevChapter: parsedData.prevChapter || null, + nextChapter: parsedData.nextChapter || null, + chapterNumber: metadata.chapterNumber || 0, + link: metadata.link || '', + lastReadPosition: 0, + downloadedAt: new Date().toISOString(), +}); + +/** + * Parses NovelFire chapter format specifically + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @returns {Object} - Parsed chapter data + */ +export const parseNovelFireChapter = ($, config = {}) => { + return parseNovelChapter($, { + contentSelector: '#content, .chapter-content', + titleSelector: 'h4, h1.chapter-title, .chapter-title', + prevSelector: '.prevchap, a.prev', + nextSelector: '.nextchap, a.next', + excludeSelectors: ['.ads', '.advertisement', '.ad-container'], + ...config, + }); +}; + +/** + * Parses ReadLightNovel chapter format + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @returns {Object} - Parsed chapter data + */ +export const parseReadLightNovelChapter = ($, config = {}) => { + return parseNovelChapter($, { + contentSelector: '.chapter-content, .text-content', + titleSelector: 'h1, h2.chapter-title', + prevSelector: '.prev a, a[rel="prev"]', + nextSelector: '.next a, a[rel="next"]', + excludeSelectors: ['.ads', '.ad-block'], + ...config, + }); +}; + +const normalizeWTRLabParagraphs = rawContent => { + if (Array.isArray(rawContent)) { + return rawContent + .flatMap(entry => { + if (typeof entry === 'string') { + return entry; + } + + if (entry && typeof entry === 'object') { + return ( + entry.text || + entry.content || + entry.body || + entry.value || + '' + ); + } + + return ''; + }) + .map(text => cleanText(String(text || '').trim())) + .filter(Boolean); + } + + if (typeof rawContent !== 'string' || rawContent.length === 0) { + return []; + } + + const normalizedText = rawContent + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<\/div>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\\n/g, '\n') + .replace(/\r\n/g, '\n'); + + return normalizedText + .split(/\n\n+|\n+/) + .map(paragraph => cleanText(paragraph.trim())) + .filter(Boolean); +}; + +/** + * WTR-Lab specific chapter parser + * WTR-Lab has a unique structure where content may be in different containers + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @param {Object} readerApiData - Data from the WTR-Lab reader API (optional) + * @param {Object} chapterMetadata - Chapter metadata from __NEXT_DATA__ (optional) + * @returns {Object} - Parsed chapter data + */ +export const parseWTRLabChapter = ($, config = {}, readerApiData = null, chapterMetadata = null) => { + console.log('[parseWTRLabChapter] Starting WTR-Lab chapter parsing'); + console.log('[parseWTRLabChapter] readerApiData:', readerApiData ? 'provided' : 'not provided'); + console.log('[parseWTRLabChapter] chapterMetadata:', chapterMetadata ? 'provided' : 'not provided'); + + const content = { + title: '', + paragraphs: [], + text: '', + images: [], + prevChapter: null, + nextChapter: null, + }; + + // If we have reader API data, use it directly + if (readerApiData) { + console.log('[parseWTRLabChapter] Processing reader API data'); + console.log('[parseWTRLabChapter] readerApiData keys:', Object.keys(readerApiData)); + + // Log the full response for debugging + console.log('[parseWTRLabChapter] Full readerApiData:', JSON.stringify(readerApiData).substring(0, 2000)); + + // The reader API returns: { success, chapter, data: { data: { body: "arr:..." } }, tasks } + // The body field contains encrypted content with format: arr:base64:base64:base64... + let chapterText = null; + + // Check for nested data.data.body structure (WTR-Lab specific) + if (readerApiData.data?.data?.body) { + console.log('[parseWTRLabChapter] Found data.data.body field'); + const bodyValue = readerApiData.data.data.body; + console.log( + '[parseWTRLabChapter] body value type:', + Array.isArray(bodyValue) ? 'array' : typeof bodyValue, + ); + console.log('[parseWTRLabChapter] body value preview:', String(bodyValue).substring(0, 200)); + + // Check if it's the arr: encrypted format + if (typeof bodyValue === 'string' && bodyValue.startsWith('arr:')) { + console.log('[parseWTRLabChapter] Found arr: encrypted format'); + + // Try to decrypt the arr: format + const decrypted = decryptWTRLabContent(bodyValue); + if (decrypted) { + console.log('[parseWTRLabChapter] Successfully decrypted arr: content'); + chapterText = decrypted; + } else { + // Fallback: try to decode each segment + console.log('[parseWTRLabChapter] Decryption failed, trying segment decoding'); + const segments = bodyValue.substring(4).split(':'); + console.log('[parseWTRLabChapter] Number of segments:', segments.length); + + // Try to decode each segment + const decodedParts = []; + for (let i = 0; i < segments.length; i++) { + try { + const decoded = base64Decode(segments[i]); + console.log(`[parseWTRLabChapter] Segment ${i} decoded length:`, decoded.length); + console.log(`[parseWTRLabChapter] Segment ${i} preview:`, decoded.substring(0, 100)); + decodedParts.push(decoded); + } catch (e) { + console.log(`[parseWTRLabChapter] Failed to decode segment ${i}:`, e.message); + } + } + + // The content might be in one of the decoded segments + for (const part of decodedParts) { + if (part && part.length > 100 && !part.includes('\x00') && /[a-zA-Z]{10,}/.test(part)) { + console.log('[parseWTRLabChapter] Found text-like content in decoded segment'); + chapterText = part; + break; + } + } + + // If no text found, try combining all decoded parts + if (!chapterText && decodedParts.length > 0) { + chapterText = decodedParts.join(''); + } + } + } else { + // Not arr: format, use directly. AI mode now returns an array of paragraphs. + chapterText = bodyValue; + } + } + + // Check for data.data.content as fallback + if (!chapterText && readerApiData.data?.data?.content) { + console.log('[parseWTRLabChapter] Found data.data.content field'); + chapterText = readerApiData.data.data.content; + } + + // Check for 'data' field which might contain the content + if (!chapterText && readerApiData.data) { + console.log('[parseWTRLabChapter] Found data field, type:', typeof readerApiData.data); + if (typeof readerApiData.data === 'string') { + chapterText = readerApiData.data; + } else if (typeof readerApiData.data === 'object') { + // Data might be an object with content inside + chapterText = readerApiData.data.content || + readerApiData.data.text || + readerApiData.data.body || + readerApiData.data.html || + readerApiData.data.chapter_text; + console.log('[parseWTRLabChapter] data object keys:', Object.keys(readerApiData.data)); + } + } + + // Check other common field names + if (!chapterText) { + chapterText = readerApiData.content || + readerApiData.text || + readerApiData.body || + readerApiData.html || + readerApiData.chapter_text || + readerApiData.chapter_content; + } + + // Check for 'encrypted' or 'encoded' data that needs decoding + if (!chapterText && readerApiData.encrypted) { + console.log('[parseWTRLabChapter] Found encrypted field'); + try { + const decoded = base64Decode(readerApiData.encrypted); + console.log('[parseWTRLabChapter] Decoded encrypted data length:', decoded.length); + chapterText = decoded; + } catch (e) { + console.log('[parseWTRLabChapter] Failed to decode encrypted data:', e.message); + } + } + + // Check for 'encoded' field + if (!chapterText && readerApiData.encoded) { + console.log('[parseWTRLabChapter] Found encoded field'); + try { + const decoded = base64Decode(readerApiData.encoded); + console.log('[parseWTRLabChapter] Decoded encoded data length:', decoded.length); + chapterText = decoded; + } catch (e) { + console.log('[parseWTRLabChapter] Failed to decode encoded data:', e.message); + } + } + + // Check for 'raw' field + if (!chapterText && readerApiData.raw) { + console.log('[parseWTRLabChapter] Found raw field'); + chapterText = readerApiData.raw; + } + + // Check for 'result' field + if (!chapterText && readerApiData.result) { + console.log('[parseWTRLabChapter] Found result field'); + if (typeof readerApiData.result === 'string') { + chapterText = readerApiData.result; + } else if (typeof readerApiData.result === 'object') { + chapterText = readerApiData.result.content || + readerApiData.result.text || + readerApiData.result.body || + readerApiData.result.data; + } + } + + // Check for 'chapter' field + if (!chapterText && readerApiData.chapter) { + console.log('[parseWTRLabChapter] Found chapter field'); + if (typeof readerApiData.chapter === 'string') { + chapterText = readerApiData.chapter; + } else if (typeof readerApiData.chapter === 'object') { + chapterText = readerApiData.chapter.content || + readerApiData.chapter.text || + readerApiData.chapter.body || + readerApiData.chapter.data; + } + } + + // Check for 'html' field directly + if (!chapterText && readerApiData.html) { + console.log('[parseWTRLabChapter] Found html field'); + chapterText = readerApiData.html; + } + + // If we found chapter text, process it + const normalizedParagraphs = normalizeWTRLabParagraphs(chapterText); + + if (normalizedParagraphs.length > 0) { + console.log( + '[parseWTRLabChapter] Found chapter text, paragraphs:', + normalizedParagraphs.length, + ); + console.log( + '[parseWTRLabChapter] Chapter text preview:', + normalizedParagraphs.join('\n\n').substring(0, 300), + ); + + content.paragraphs = normalizedParagraphs; + content.text = content.paragraphs.join('\n\n'); + + console.log('[parseWTRLabChapter] Extracted from reader API - paragraphs:', content.paragraphs.length); + console.log('[parseWTRLabChapter] First paragraph preview:', content.paragraphs[0] ? content.paragraphs[0].substring(0, 100) : 'empty'); + } else { + console.log('[parseWTRLabChapter] No chapter text found in reader API data'); + console.log('[parseWTRLabChapter] Available fields:', Object.keys(readerApiData)); + + // Log all field values to find content + for (const [key, value] of Object.entries(readerApiData)) { + if (typeof value === 'string') { + console.log(`[parseWTRLabChapter] Field '${key}' (string):`, value.substring(0, 200)); + } else if (typeof value === 'object' && value !== null) { + console.log(`[parseWTRLabChapter] Field '${key}' (object):`, JSON.stringify(value).substring(0, 200)); + } else { + console.log(`[parseWTRLabChapter] Field '${key}':`, value); + } + } + } + + // Get title from metadata or reader data + content.title = chapterMetadata?.title || + readerApiData.title || + readerApiData.chapter_title || + content.title; + + // If we found content, return it + if (content.paragraphs.length > 0) { + console.log('[parseWTRLabChapter] Successfully extracted from reader API'); + return content; + } + } + + // WTR-Lab is a Next.js app - try to extract content from __NEXT_DATA__ + const nextDataScript = $('script#__NEXT_DATA__').html(); + if (nextDataScript) { + try { + console.log('[parseWTRLabChapter] Found __NEXT_DATA__'); + const nextData = JSON.parse(nextDataScript); + console.log('[parseWTRLabChapter] __NEXT_DATA__ keys:', Object.keys(nextData)); + + // Navigate through the Next.js data structure to find chapter content + const props = nextData?.props || {}; + const pageProps = props?.pageProps || {}; + + console.log('[parseWTRLabChapter] pageProps keys:', Object.keys(pageProps)); + + // Log the FULL pageProps for debugging + console.log('[parseWTRLabChapter] FULL pageProps:', JSON.stringify(pageProps, null, 2).substring(0, 2000)); + + // WTR-Lab stores data in 'serie' key + const serie = pageProps?.serie || {}; + + console.log('[parseWTRLabChapter] serie keys:', Object.keys(serie)); + + // Log the FULL serie object for debugging + console.log('[parseWTRLabChapter] FULL serie:', JSON.stringify(serie, null, 2).substring(0, 3000)); + + // Look for chapter content in serie + if (serie) { + // Check for serie_data - this might contain the content + const serieData = serie?.serie_data || null; + if (serieData) { + console.log('[parseWTRLabChapter] serie_data keys:', Object.keys(serieData)); + console.log('[parseWTRLabChapter] FULL serie_data:', JSON.stringify(serieData, null, 2).substring(0, 2000)); + + // Check for content in serie_data + if (serieData.data) { + console.log('[parseWTRLabChapter] serie_data.data keys:', Object.keys(serieData.data)); + console.log('[parseWTRLabChapter] serie_data.data:', JSON.stringify(serieData.data, null, 2).substring(0, 2000)); + } + } + + // Check for chapter data + const chapterData = serie?.chapter || serie?.currentChapter || null; + console.log('[parseWTRLabChapter] chapterData:', chapterData ? 'found' : 'not found'); + + if (chapterData) { + console.log('[parseWTRLabChapter] chapterData keys:', Object.keys(chapterData)); + console.log('[parseWTRLabChapter] FULL chapterData:', JSON.stringify(chapterData, null, 2)); + + // Extract title + content.title = chapterData.title || chapterData.name || chapterData.chapterTitle || ''; + + // Extract text content - WTR-Lab stores content in 'code' field + let chapterText = chapterData.code || + chapterData.content || + chapterData.text || + chapterData.body || + chapterData.html || + ''; + + console.log('[parseWTRLabChapter] chapterText length:', chapterText ? chapterText.length : 0); + console.log('[parseWTRLabChapter] chapterText preview:', chapterText ? chapterText.substring(0, 200) : 'empty'); + + if (chapterText && typeof chapterText === 'string' && chapterText.length > 10) { + // Handle HTML entities and tags + chapterText = chapterText + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<\/div>/gi, '\n') + .replace(/<[^>]+>/g, '') // Remove remaining HTML tags + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\\n/g, '\n') // Handle escaped newlines + .replace(/\r\n/g, '\n'); + + // Split into paragraphs + const paragraphs = chapterText.split(/\n\n+|\n+/).filter(p => p.trim().length > 0); + content.paragraphs = paragraphs.map(p => cleanText(p.trim())); + content.text = content.paragraphs.join('\n\n'); + + console.log('[parseWTRLabChapter] Extracted from chapterData - paragraphs:', content.paragraphs.length); + console.log('[parseWTRLabChapter] First paragraph preview:', content.paragraphs[0] ? content.paragraphs[0].substring(0, 100) : 'empty'); + return content; // Return early since we found content + } + } + + // Check for API data fetched by Reader.js + if ($._wtrLabApiData) { + console.log('[parseWTRLabChapter] Found API data from Reader.js'); + const apiData = $._wtrLabApiData; + console.log('[parseWTRLabChapter] API data keys:', Object.keys(apiData)); + + // Try to extract content from API response + let apiContent = apiData.content || apiData.text || apiData.body || + apiData.code || apiData.data?.content || apiData.data?.text || + apiData.chapter?.content || apiData.chapter?.text; + + if (apiContent && typeof apiContent === 'string' && apiContent.length > 10) { + console.log('[parseWTRLabChapter] API content length:', apiContent.length); + console.log('[parseWTRLabChapter] API content preview:', apiContent.substring(0, 200)); + + // Handle HTML entities and tags + apiContent = apiContent + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<\/div>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\\n/g, '\n') + .replace(/\r\n/g, '\n'); + + const paragraphs = apiContent.split(/\n\n+|\n+/).filter(p => p.trim().length > 0); + content.paragraphs = paragraphs.map(p => cleanText(p.trim())); + content.text = content.paragraphs.join('\n\n'); + + // Get title from API if available + content.title = apiData.title || apiData.name || apiData.chapter?.title || content.title; + + console.log('[parseWTRLabChapter] Extracted from API - paragraphs:', content.paragraphs.length); + return content; + } + } + + // If no chapter data, check for chapters array and find current chapter + if (!content.text && serie?.chapters) { + console.log('[parseWTRLabChapter] Found chapters array, length:', serie.chapters.length); + } + + // Check for content directly in serie + if (!content.text && serie?.content) { + console.log('[parseWTRLabChapter] Found content in serie'); + let chapterText = serie.content; + if (typeof chapterText === 'string') { + chapterText = chapterText.replace(//gi, '\n').replace(/<\/p>/gi, '\n\n'); + chapterText = chapterText.replace(/<[^>]+>/g, ''); + const paragraphs = chapterText.split(/\n\n+|\n+/).filter(p => p.trim().length > 0); + content.paragraphs = paragraphs.map(p => cleanText(p.trim())); + content.text = content.paragraphs.join('\n\n'); + } + } + + // Extract title from serie if not found + if (!content.title && serie?.title) { + content.title = serie.title; + } + } + + // Look for chapter content in various possible locations + if (!content.text) { + let chapterData = pageProps?.chapter || + pageProps?.content || + pageProps?.chapterContent || + pageProps?.data?.chapter || + null; + + if (chapterData) { + console.log('[parseWTRLabChapter] Found chapterData keys:', Object.keys(chapterData)); + + // Extract title + content.title = chapterData.title || chapterData.name || chapterData.chapterTitle || ''; + + // Extract text content - WTR-Lab stores content in 'code' field + let chapterText = chapterData.code || + chapterData.content || + chapterData.text || + chapterData.body || + chapterData.html || + ''; + + if (chapterText && typeof chapterText === 'string') { + // Handle HTML entities and tags + chapterText = chapterText + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<\/div>/gi, '\n') + .replace(/<[^>]+>/g, '') // Remove remaining HTML tags + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\\n/g, '\n') // Handle escaped newlines + .replace(/\r\n/g, '\n'); + + // Split into paragraphs + const paragraphs = chapterText.split(/\n\n+|\n+/).filter(p => p.trim().length > 0); + content.paragraphs = paragraphs.map(p => cleanText(p.trim())); + content.text = content.paragraphs.join('\n\n'); + + console.log('[parseWTRLabChapter] Extracted from __NEXT_DATA__ - paragraphs:', content.paragraphs.length); + } + } + } + } catch (e) { + console.log('[parseWTRLabChapter] Error parsing __NEXT_DATA__:', e.message); + } + } + + // If we found content from __NEXT_DATA__, return it + if (content.text && content.paragraphs.length > 0) { + console.log('[parseWTRLabChapter] Successfully extracted from __NEXT_DATA__'); + return content; + } + + // Fallback: Try to extract from HTML directly + console.log('[parseWTRLabChapter] __NEXT_DATA__ not found or empty, trying HTML extraction'); + + // Extract title - WTR-Lab typically has title in h1 or h2 + const titleSelectors = ['h1', 'h2', '.chapter-title', 'h3']; + for (const selector of titleSelectors) { + const titleEl = $(selector).first(); + const titleText = titleEl.text().trim(); + if (titleText && titleText.length > 5 && !titleText.toLowerCase().includes('wtr-lab')) { + content.title = titleText; + console.log('[parseWTRLabChapter] Found title:', content.title.substring(0, 50)); + break; + } + } + + // WTR-Lab content selectors to try - be more aggressive + const contentSelectors = [ + // WTR-Lab specific - try these first + 'div[class*="chapter"]', + 'div[class*="content"]', + 'div[class*="reader"]', + 'div[class*="text"]', + 'div[class*="novel"]', + // Standard selectors + '.chapter-content', + 'article', + '.content', + '.chapter-body', + '.text-content', + 'main', + '#content', + '.chapter-text', + '.reader-content', + '.novel-content', + '.text-reader', + // Very broad selectors + 'section', + '.post', + '.entry-content', + '.post-content', + ]; + + let container = null; + let foundContent = false; + + // Try each selector + for (const selector of contentSelectors) { + const elements = $(selector); + elements.each((i, el) => { + const $el = $(el); + const paragraphs = $el.find('p'); + const text = $el.text().trim(); + const divCount = $el.find('div').length; + + // Log for debugging + if (text.length > 100) { + console.log('[parseWTRLabChapter] Selector:', selector, 'element:', i, 'text length:', text.length, 'paragraphs:', paragraphs.length, 'child divs:', divCount); + } + + // Look for content with substantial text or paragraphs + // Also check if this div has direct text content (not just nested divs) + const directText = $el.clone().children().remove().end().text().trim(); + + if ((paragraphs.length >= 3 || text.length > 1000) && !foundContent) { + // Check if this looks like chapter content + const lowerText = text.toLowerCase(); + const isNavigation = lowerText.includes('prev ch') && lowerText.includes('next ch'); + const isFooter = lowerText.includes('copyright') && lowerText.includes('wtr-lab'); + + if (!isNavigation && !isFooter) { + container = $el; + console.log('[parseWTRLabChapter] Found content with selector:', selector, 'element:', i); + console.log('[parseWTRLabChapter] Content preview:', text.substring(0, 300)); + foundContent = true; + return false; // Break the each loop + } + } + }); + + if (foundContent) break; + } + + // If no container found with standard selectors, try to find all paragraphs in body + if (!foundContent) { + console.log('[parseWTRLabChapter] No standard container found, trying body paragraphs'); + + // Get all paragraphs from body, excluding navigation and footer + const allParagraphs = $('body').find('p').not('footer p').not('nav p').not('.footer p').not('.nav p'); + + console.log('[parseWTRLabChapter] Found body paragraphs:', allParagraphs.length); + + // Log each paragraph for debugging + allParagraphs.each((i, el) => { + const text = $(el).text().trim(); + if (text.length > 20) { + console.log('[parseWTRLabChapter] Paragraph', i, ':', text.substring(0, 100)); + } + }); + + // Filter out short paragraphs and navigation text + // WTR-Lab has many short paragraphs, so use a lower threshold + const validParagraphs = []; + allParagraphs.each((_, el) => { + const text = $(el).text().trim(); + // Accept paragraphs > 5 characters that aren't navigation + // WTR-Lab has short sentences like "hot.", "It's hot." + if (text.length > 5 && + !text.toLowerCase().includes('prev ch') && + !text.toLowerCase().includes('next ch') && + !text.toLowerCase().match(/^chapter\s*\d+\s*\//) && + !text.includes('Problematic ad?') && + !text.includes('Become a contributor') && + !text.includes('Copyright ©') && + !text.includes('wtr-lab.com') && + !text.toLowerCase().includes('report it here') && + !text.toLowerCase().includes('disable popup')) { + validParagraphs.push(text); + } + }); + + console.log('[parseWTRLabChapter] Valid paragraphs after filtering:', validParagraphs.length); + + if (validParagraphs.length >= 3) { + content.paragraphs = validParagraphs.map(p => cleanText(p)); + content.text = content.paragraphs.join('\n\n'); + foundContent = true; + } + } + + // If still no content, try extracting text directly from body + // WTR-Lab might not use

tags + if (!foundContent) { + console.log('[parseWTRLabChapter] Trying to extract text directly from body'); + + // Get the raw HTML from body for inspection + const bodyHtml = $('body').html() || ''; + console.log('[parseWTRLabChapter] Body HTML length for raw extraction:', bodyHtml.length); + console.log('[parseWTRLabChapter] Body HTML preview:', bodyHtml.substring(0, 1000)); + + // Get the body text BEFORE removing elements + const bodyTextBeforeCleanup = $('body').text(); + console.log('[parseWTRLabChapter] Body text length before cleanup:', bodyTextBeforeCleanup.length); + console.log('[parseWTRLabChapter] Body text preview:', bodyTextBeforeCleanup.substring(0, 500)); + + // Check if the chapter content is actually in the body + const contentIndicators = ['Fang Xiaoluo', 'On the 23rd day', 'hot', 'It\'s hot', 'Traveling Through Time']; + for (const indicator of contentIndicators) { + if (bodyTextBeforeCleanup.includes(indicator)) { + console.log('[parseWTRLabChapter] Found indicator in body text:', indicator); + } + if (bodyHtml.includes(indicator)) { + console.log('[parseWTRLabChapter] Found indicator in body HTML:', indicator); + const idx = bodyHtml.indexOf(indicator); + console.log('[parseWTRLabChapter] Context around', indicator, ':', bodyHtml.substring(Math.max(0, idx - 200), idx + 300)); + } + } + + // Try to find the content container by looking for specific patterns + // WTR-Lab content might be in a specific div structure + console.log('[parseWTRLabChapter] Searching for content in all elements...'); + + // Search all elements for chapter content + $('*').each((i, el) => { + const $el = $(el); + const tagName = el.tagName?.toLowerCase(); + const text = $el.text().trim(); + const className = $el.attr('class') || ''; + + // Skip script, style, and navigation elements + if (['script', 'style', 'nav', 'footer', 'header'].includes(tagName)) { + return; + } + + // Look for elements containing chapter content indicators + if (text.length > 100 && ( + text.includes('Fang Xiaoluo') || + text.includes('hot') || + text.includes('Traveling Through Time') + )) { + console.log('[parseWTRLabChapter] Found content in element:', tagName, 'class:', className); + console.log('[parseWTRLabChapter] Element text length:', text.length); + console.log('[parseWTRLabChapter] Element text preview:', text.substring(0, 300)); + } + }); + + // Split by newlines and filter + const lines = bodyTextBeforeCleanup.split(/\n+/); + const validLines = []; + + for (const line of lines) { + const trimmed = line.trim(); + // Filter out short lines, navigation, and common non-content text + // Be more lenient - accept lines > 5 chars that don't match navigation patterns + // WTR-Lab has short sentences like "hot.", "It's hot." + if (trimmed.length > 5 && + !trimmed.toLowerCase().includes('prev ch') && + !trimmed.toLowerCase().includes('next ch') && + !trimmed.toLowerCase().match(/^\d+\s*\/\s*\d+/) && + !trimmed.includes('Problematic ad?') && + !trimmed.includes('Become a contributor') && + !trimmed.includes('Copyright ©') && + !trimmed.includes('wtr-lab.com') && + !trimmed.toLowerCase().includes('intro') && + !trimmed.toLowerCase().includes('about us') && + !trimmed.toLowerCase().includes('contact us') && + !trimmed.toLowerCase().includes('privacy policy') && + !trimmed.toLowerCase().includes('terms of use') && + !trimmed.toLowerCase().includes('cookie policy') && + !trimmed.toLowerCase().includes('changelog') && + !trimmed.toLowerCase().includes('dmca') && + !trimmed.includes('ads by') && + !trimmed.includes('Pubfuture') && + !trimmed.toLowerCase().includes('report it here') && + !trimmed.toLowerCase().includes('disable popup') && + !trimmed.match(/^\[Image/i) && + !trimmed.match(/^image$/i)) { + validLines.push(trimmed); + } + } + + console.log('[parseWTRLabChapter] Valid lines from body:', validLines.length); + if (validLines.length > 0) { + console.log('[parseWTRLabChapter] First 3 lines:', validLines.slice(0, 3)); + } + + if (validLines.length >= 3) { + content.paragraphs = validLines.map(p => cleanText(p)); + content.text = content.paragraphs.join('\n\n'); + foundContent = true; + } + } + + // If we found a container, extract paragraphs from it + if (container && !content.paragraphs.length) { + container.find('p').each((_, el) => { + const text = $(el).text().trim(); + if (text && text.length > 10) { + content.paragraphs.push(cleanText(text)); + } + }); + + // If still no paragraphs, try getting text directly + if (content.paragraphs.length === 0) { + const directText = container.text().trim(); + if (directText && directText.length > 100) { + // Split by double newlines or common paragraph separators + const parts = directText.split(/\n\n+|\r\n\r\n+/); + parts.forEach(part => { + const cleaned = cleanText(part); + if (cleaned && cleaned.length > 10) { + content.paragraphs.push(cleaned); + } + }); + } + } + + content.text = content.paragraphs.join('\n\n'); + } + + // LAST RESORT: If still no content, try to extract from raw HTML + // This handles cases where cheerio parsing fails but content is in HTML + if (!foundContent || content.paragraphs.length < 3) { + console.log('[parseWTRLabChapter] Trying raw HTML extraction as last resort'); + + // Get the raw HTML from body + const bodyHtml = $('body').html() || ''; + console.log('[parseWTRLabChapter] Body HTML length for raw extraction:', bodyHtml.length); + + // Look for text between specific patterns + // WTR-Lab content is often in divs without specific classes + // Try to find text that looks like chapter content + + // Method 1: Extract text between common patterns + // Remove script and style tags first + let cleanHtml = bodyHtml + .replace(/]*>[\s\S]*?<\/script>/gi, '') + .replace(/]*>[\s\S]*?<\/style>/gi, '') + .replace(/]*>[\s\S]*?<\/nav>/gi, '') + .replace(/]*>[\s\S]*?<\/footer>/gi, '') + .replace(/]*>[\s\S]*?<\/header>/gi, ''); + + // Extract text content from remaining HTML + // Handle
and

as paragraph breaks + cleanHtml = cleanHtml + .replace(//gi, '\n') + .replace(/<\/p>/gi, '\n\n') + .replace(/<\/div>/gi, '\n') + .replace(/<[^>]+>/g, ' ') // Remove remaining tags + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/\s+/g, ' ') + .trim(); + + console.log('[parseWTRLabChapter] Clean HTML text length:', cleanHtml.length); + console.log('[parseWTRLabChapter] Clean HTML preview:', cleanHtml.substring(0, 500)); + + // Split into paragraphs and filter + const rawParagraphs = cleanHtml.split(/\n+/).map(p => p.trim()).filter(p => { + // Filter out navigation, footer, and short text + // WTR-Lab has short sentences, so use lower threshold + const lowerP = p.toLowerCase(); + return p.length > 5 && + !lowerP.includes('prev ch') && + !lowerP.includes('next ch') && + !lowerP.includes('copyright') && + !lowerP.includes('wtr-lab.com') && + !lowerP.includes('problematic ad') && + !lowerP.includes('become a contributor') && + !lowerP.includes('intro') && + !lowerP.includes('about us') && + !lowerP.includes('contact us') && + !lowerP.includes('privacy policy') && + !lowerP.includes('terms of use') && + !lowerP.includes('cookie policy') && + !lowerP.includes('changelog') && + !lowerP.includes('dmca') && + !lowerP.includes('ads by') && + !lowerP.includes('pubfuture') && + !lowerP.match(/^\d+\s*\/\s*\d+/) && + !lowerP.match(/^\[image/i) && + !lowerP.includes('report it here') && + !lowerP.includes('disable popup') && + !lowerP.match(/^image$/i); + }); + + console.log('[parseWTRLabChapter] Raw paragraphs extracted:', rawParagraphs.length); + if (rawParagraphs.length > 0) { + console.log('[parseWTRLabChapter] First 3 raw paragraphs:', rawParagraphs.slice(0, 3)); + } + + if (rawParagraphs.length >= 3) { + content.paragraphs = rawParagraphs; + content.text = content.paragraphs.join('\n\n'); + foundContent = true; + } + } + + console.log('[parseWTRLabChapter] Final paragraphs count:', content.paragraphs.length); + console.log('[parseWTRLabChapter] Text length:', content.text.length); + + // Extract navigation links + // WTR-Lab has "Prev" and "Next" links + $('a').each((_, el) => { + const href = $(el).attr('href'); + const text = $(el).text().trim().toLowerCase(); + + if (href && !href.toLowerCase().startsWith('javascript')) { + if (text === 'prev' || text.includes('prev ch')) { + content.prevChapter = href; + console.log('[parseWTRLabChapter] Found prev chapter:', href); + } else if (text === 'next' || text.includes('next ch')) { + content.nextChapter = href; + console.log('[parseWTRLabChapter] Found next chapter:', href); + } + } + }); + + // Also try standard navigation selectors + if (!content.prevChapter) { + const prevEl = $('a:contains("Prev"), a.prev, a[rel="prev"]').first(); + const prevHref = prevEl.attr('href'); + if (prevHref && !prevHref.toLowerCase().startsWith('javascript')) { + content.prevChapter = prevHref; + } + } + + if (!content.nextChapter) { + const nextEl = $('a:contains("Next"), a.next, a[rel="next"]').first(); + const nextHref = nextEl.attr('href'); + if (nextHref && !nextHref.toLowerCase().startsWith('javascript')) { + content.nextChapter = nextHref; + } + } + + return content; +}; + +export default { + parseNovelChapter, + extractChapterText, + extractChapterImages, + parsePaginatedChapter, + parseChapterWithHtml, + createChapterData, + parseNovelFireChapter, + parseReadLightNovelChapter, + parseWTRLabChapter, +}; diff --git a/src/Redux/Actions/parsers/novelDetailParser.js b/src/Redux/Actions/parsers/novelDetailParser.js new file mode 100644 index 00000000..9d1175a9 --- /dev/null +++ b/src/Redux/Actions/parsers/novelDetailParser.js @@ -0,0 +1,613 @@ +/** + * Novel detail parsing utilities + */ + +import {normalizeImageUrl} from '../utils/dataHelpers'; + +/** + * Parses novel details using standard configuration + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @param {string} link - Novel URL + * @returns {Object} - Parsed novel details + */ +export const parseNovelDetails = ($, config, link) => { + const detailsContainer = $(config.detailsContainer); + const title = $(config.title).first().text().trim(); + + // Try to get image from configured selector, check both src and data-src + const $img = detailsContainer.find(config.imgSrc); + let imgSrc = $img.attr(config.getImageAttr) || + $img.attr('data-src') || + $img.attr('src'); + imgSrc = normalizeImageUrl(imgSrc); + + const genres = parseNovelGenres($, config); + const details = parseDetailsSection($, detailsContainer, config); + + // Parse summary - extract paragraphs if available + let summary = ''; + if (config.summary) { + const $summary = $(config.summary); + const paragraphs = []; + $summary.find('p').each((_, el) => { + const text = $(el).text().trim(); + if (text) { + paragraphs.push(text); + } + }); + summary = paragraphs.length > 0 ? paragraphs.join('\n\n') : $summary.text().trim(); + } + + // Parse author directly if selector exists + const author = config.author ? $(config.author).first().text().trim() : null; + + // Parse rating directly if selector exists + const rating = parseRating($, config); + + return { + title, + imgSrc, + type: details.Type || 'Novel', + status: details.Status || parseStatusFromText($, config) || 'Ongoing', + genres: genres.length > 0 ? genres : details.Genres || [], + author: author || details.Author || details['Author(s)'] || null, + alternativeName: details.Alternative || details['Alternative name'] || details['Alternative Title'] || null, + views: details.Views || null, + rating: rating || details.Rating || null, + summary, + link, + // Novel-specific fields + chapters: details.Chapters || details['Total Chapters'] || null, + bookmarked: details.Bookmarked || details.Bookmarks || null, + tags: details.Tags || [], + source: details.Source || null, + year: details.Year || details['Release Year'] || null, + }; +}; + +/** + * Parses genre information from the page + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @returns {Array} - Array of genre strings + */ +export const parseNovelGenres = ($, config) => { + const genres = []; + if (config.genre) { + $(config.genre).each((_, el) => { + const genreText = $(el).text().trim(); + if (genreText) { + genres.push(genreText); + } + }); + } + return genres; +}; + +/** + * Parses the details section (dl/dt/dd or table structure) + * + * @param {Object} $ - Cheerio instance + * @param {Object} detailsContainer - The container element + * @param {Object} config - Parser configuration + * @returns {Object} - Parsed details as key-value pairs + */ +const parseDetailsSection = ($, detailsContainer, config) => { + const details = {}; + + if (!config.detailsDL) { + return details; + } + + // Table-based structure + if (config.detailsValue) { + detailsContainer.find(config.detailsDL).each((i, el) => { + const key = $(el).text().trim().replace(':', ''); + const valueCell = $(el).next(config.detailsValue); + if (valueCell.length) { + const links = valueCell.find('a'); + if (links.length > 0) { + const list = []; + links.each((_, a) => list.push($(a).text().trim())); + details[key] = list; + } else { + details[key] = valueCell.text().trim(); + } + } + }); + } + // Standard dl/dt/dd structure + else { + detailsContainer.find(config.detailsDL).each((i, el) => { + const key = $(el).text().trim().replace(':', ''); + const dd = $(el).next('dd'); + const keyLower = key.toLowerCase(); + + if (keyLower === 'tags' || keyLower === 'genres') { + const list = []; + dd.find('a').each((_, a) => list.push($(a).text().trim())); + details[key] = list; + } else { + details[key] = dd.text().trim(); + } + }); + } + + return details; +}; + +/** + * Parses status from text content when not in details section + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @returns {string|null} - Status string or null + */ +const parseStatusFromText = ($, config) => { + if (!config.status) { + return null; + } + + const statusText = $(config.status).text().trim().toLowerCase(); + + if (statusText.includes('completed') || statusText === 'completed') { + return 'Completed'; + } + if (statusText.includes('ongoing') || statusText === 'ongoing') { + return 'Ongoing'; + } + if (statusText.includes('hiatus') || statusText === 'hiatus') { + return 'Hiatus'; + } + if (statusText.includes('cancelled') || statusText === 'cancelled') { + return 'Cancelled'; + } + + return statusText || null; +}; + +/** + * Parses rating from the page + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @returns {number|null} - Rating value or null + */ +const parseRating = ($, config) => { + if (!config.rating) { + return null; + } + + const ratingText = $(config.rating).text().trim(); + const ratingMatch = ratingText.match(/(\d+\.?\d*)/); + + return ratingMatch ? parseFloat(ratingMatch[1]) : null; +}; + +/** + * Parses chapter list with pagination info + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @returns {Object} - Object containing chapters array and pagination info + */ +export const parseChapterList = ($, config) => { + const chapters = []; + const pagination = { + currentPage: 1, + totalPages: 1, + hasNext: false, + hasPrev: false, + totalChapters: 0, + }; + + // Support both 'chapterList' and 'chaptersContainer' property names + const containerSelector = config.chapterList || config.chaptersContainer; + + if (!config.chapterItem) { + return {chapters, pagination}; + } + + // Try to find chapters in container first, then fall back to entire document + let $context; + if (containerSelector && $(containerSelector).length > 0) { + $context = $(containerSelector); + console.log('[parseChapterList] Using container:', containerSelector); + } else { + // Fall back to searching entire document + $context = $.root(); + console.log('[parseChapterList] Falling back to root, container not found:', containerSelector); + } + + // Debug: Log how many chapter items we find + const foundItems = $context.find(config.chapterItem).length; + console.log('[parseChapterList] Found chapter items:', foundItems); + + // Parse chapters + $context.find(config.chapterItem).each((index, el) => { + const $el = $(el); + const $link = config.chapterLink ? $el.find(config.chapterLink) : $el; + + let chapterLink = $link.attr('href') || $el.attr('href'); + const chapterTitle = config.chapterTitle + ? $el.find(config.chapterTitle).text().trim() + : $link.text().trim() || $el.text().trim(); + + // Extract chapter number from link or title + let chapterNumber = index + 1; + if (chapterLink) { + const numberMatch = chapterLink.match(/chapter-(\d+)/i) || + chapterLink.match(/\/(\d+)\/?$/); + if (numberMatch) { + chapterNumber = parseInt(numberMatch[1], 10); + } + } else if (chapterTitle) { + const titleMatch = chapterTitle.match(/chapter\s*(\d+)/i) || + chapterTitle.match(/(\d+)/); + if (titleMatch) { + chapterNumber = parseInt(titleMatch[1], 10); + } + } + + if (chapterLink) { + chapters.push({ + number: chapterNumber, + title: chapterTitle || `Chapter ${chapterNumber}`, + link: chapterLink, + }); + } + }); + + // Remove duplicates based on chapter number + const uniqueChapters = []; + const seenNumbers = new Set(); + chapters.forEach((chapter) => { + if (!seenNumbers.has(chapter.number)) { + seenNumbers.add(chapter.number); + uniqueChapters.push(chapter); + } + }); + + // Parse pagination using both DOM and regex methods + const html = $.html(); + + // Method 1: DOM-based pagination + if (config.pagination) { + const $pagination = $(config.pagination); + + // Find current page + if (config.currentPage) { + const $current = $pagination.find(config.currentPage); + if ($current.length) { + pagination.currentPage = parseInt($current.text().trim(), 10) || 1; + } + } + + // Check for next/prev links + if (config.nextPage) { + pagination.hasNext = $pagination.find(config.nextPage).length > 0; + } + if (config.prevPage) { + pagination.hasPrev = $pagination.find(config.prevPage).length > 0; + } + } + + // Method 2: Regex-based pagination (more reliable for some sites) + // Find all page number links + const pageLinkPattern = /]*href="[^"]*\?page=(\d+)"[^>]*>(\d+)<\/a>/gi; + const pageMatches = []; + let match; + while ((match = pageLinkPattern.exec(html)) !== null) { + pageMatches.push(match); + } + + if (pageMatches.length > 0) { + // Get the highest page number + const pageNumbers = pageMatches.map(m => parseInt(m[1], 10)); + pagination.totalPages = Math.max(...pageNumbers); + } + + // Check for "Next" link via regex + const nextMatch = html.match(/]*href="[^"]*\?page=(\d+)"[^>]*>Next/i) || + html.match(/]*href="[^"]*\?page=(\d+)"[^>]*>»/i); + if (nextMatch) { + pagination.hasNext = true; + const nextPageNum = parseInt(nextMatch[1], 10); + if (nextPageNum > pagination.totalPages) { + pagination.totalPages = nextPageNum; + } + } + + // Check for "Previous" link via regex + const prevMatch = html.match(/]*href="[^"]*\?page=(\d+)"[^>]*>Prev/i) || + html.match(/]*href="[^"]*\?page=(\d+)"[^>]*>«/i); + if (prevMatch) { + pagination.hasPrev = true; + } + + // Extract current page from URL if not found + if (pagination.currentPage === 1) { + const currentPageMatch = html.match(/page=(\d+)/); + if (currentPageMatch) { + pagination.currentPage = parseInt(currentPageMatch[1], 10); + } + } + + // If we have many chapters but only 1 page detected, check for next page + if (pagination.totalPages === 1 && uniqueChapters.length >= 100) { + const hasNextLink = html.match(/chapters\?page=(\d+)/i); + if (hasNextLink) { + const nextPageNum = parseInt(hasNextLink[1], 10); + if (nextPageNum > pagination.currentPage) { + pagination.hasNext = true; + pagination.totalPages = Math.max(pagination.totalPages, nextPageNum); + } + } + } + + pagination.totalChapters = uniqueChapters.length; + + console.log('[parseChapterList] Pagination:', pagination); + + return { + chapters: uniqueChapters, + pagination, + }; +}; + +/** + * Parses complete novel page with both details and chapters + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @param {string} link - Novel URL + * @returns {Object} - Combined novel details with chapters + */ +export const parseNovelPage = ($, config, link) => { + const details = parseNovelDetails($, config, link); + const {chapters, pagination} = parseChapterList($, config); + + return { + ...details, + chapterList: chapters, + chapterPagination: pagination, + }; +}; + +/** + * Parses WTR-Lab specific novel details + * WTR-Lab has a unique HTML structure that requires custom parsing + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @param {string} link - Novel URL + * @returns {Object} - Parsed novel details + */ +export const parseWTRLabDetails = ($, config, link) => { + const details = { + title: null, + imgSrc: null, + author: null, + status: 'Ongoing', + summary: '', + genres: [], + tags: [], + views: null, + chapters: null, + characters: null, + readers: null, + rating: null, + link, + }; + + // Title - WTR-Lab uses h1 for the main title + details.title = $('h1').first().text().trim(); + + // Alternate title (Chinese title) - usually in h2 or after h1 + const alternateTitle = $('h2').first().text().trim(); + if (alternateTitle) { + details.alternateTitle = alternateTitle; + } + + // Cover image - use multiple approaches to find it + let coverImage = null; + + // Debug: Log all images found on the page + const allImages = []; + $('img').each((index, img) => { + const $img = $(img); + const src = $img.attr('src'); + const dataSrc = $img.attr('data-src'); + const alt = $img.attr('alt'); + if (src || dataSrc) { + allImages.push({src: src?.substring(0, 60), dataSrc: dataSrc?.substring(0, 60), alt}); + } + }); + console.log('[parseWTRLabDetails] All images found:', allImages.length); + if (allImages.length > 0) { + console.log('[parseWTRLabDetails] First 3 images:', allImages.slice(0, 3)); + } + + // First, try og:image meta tag (most reliable for cover images) + const ogImage = $('meta[property="og:image"]').attr('content'); + if (ogImage) { + coverImage = ogImage; + console.log('[parseWTRLabDetails] Found og:image:', coverImage.substring(0, 80) + '...'); + } + + // Second, try to find images with /api/v2/img in src + if (!coverImage) { + const apiImages = []; + $('img[src*="/api/v2/img"]').each((index, img) => { + const $img = $(img); + const src = $img.attr('src'); + if (src) { + apiImages.push(src); + } + }); + console.log('[parseWTRLabDetails] Found api images:', apiImages.length, apiImages.slice(0, 2)); + if (apiImages.length > 0) { + // Use the first api image (usually the cover) + coverImage = apiImages[0]; + } + } + + // Also check for data-src attributes (lazy loading) + if (!coverImage) { + const lazyImages = []; + $('img[data-src*="/api/v2/img"]').each((index, img) => { + const $img = $(img); + const src = $img.attr('data-src'); + if (src) { + lazyImages.push(src); + } + }); + console.log('[parseWTRLabDetails] Found lazy api images:', lazyImages.length); + if (lazyImages.length > 0 && !coverImage) { + coverImage = lazyImages[0]; + } + } + + // Fallback: try other image selectors + if (!coverImage) { + const coverImg = $('img[alt*="cover"], .cover img, .novel-cover img, img').first(); + if (coverImg.length) { + coverImage = coverImg.attr('src') || coverImg.attr('data-src'); + console.log('[parseWTRLabDetails] Fallback image found:', coverImage?.substring(0, 60)); + } + } + + // Process cover image URL + if (coverImage) { + // Decode HTML entities (like & -> &) + coverImage = coverImage.replace(/&/g, '&'); + + // If image URL is relative (starts with /api), prepend base URL + if (coverImage.startsWith('/api')) { + coverImage = `https://wtr-lab.com${coverImage}`; + } else if (!coverImage.startsWith('http')) { + coverImage = coverImage.startsWith('//') ? `https:${coverImage}` : + coverImage.startsWith('/') ? `https://wtr-lab.com${coverImage}` : + `https://wtr-lab.com/${coverImage}`; + } + details.imgSrc = coverImage; + } + + console.log('[parseWTRLabDetails] Final cover image:', details.imgSrc ? details.imgSrc.substring(0, 100) + '...' : null); + + // Parse the info section - WTR-Lab shows: Status, Views, Chapters, Characters, Readers + // Format: "•Ongoing · 18Views162Chapters · 277KCharacters3Readers" + const infoText = $('body').text(); + + // Status + if (infoText.includes('Completed')) { + details.status = 'Completed'; + } else if (infoText.includes('Ongoing')) { + details.status = 'Ongoing'; + } + + // Views + const viewsMatch = infoText.match(/(\d+(?:\.\d+)?[KM]?)\s*Views/i); + if (viewsMatch) { + details.views = viewsMatch[1]; + } + + // Chapters + const chaptersMatch = infoText.match(/(\d+)\s*Chapters/i); + if (chaptersMatch) { + details.chapters = parseInt(chaptersMatch[1], 10); + } + + // Characters + const charactersMatch = infoText.match(/(\d+(?:\.\d+)?[KM]?)\s*Characters/i); + if (charactersMatch) { + details.characters = charactersMatch[1]; + } + + // Readers + const readersMatch = infoText.match(/(\d+(?:\.\d+)?[KM]?)\s*Readers/i); + if (readersMatch) { + details.readers = readersMatch[1]; + } + + // Rating - look for star rating + const ratingMatch = infoText.match(/★\s*(\d+\.?\d*)/); + if (ratingMatch) { + details.rating = parseFloat(ratingMatch[1]); + } + + // Author - WTR-Lab has author links + const authorLink = $('a[href*="/author/"]').first(); + if (authorLink.length) { + details.author = authorLink.text().trim(); + } + + // Genres - WTR-Lab has genre links with /novel-list?genre= + $('a[href*="/novel-list?genre="]').each((_, el) => { + const genre = $(el).text().trim(); + if (genre && !details.genres.includes(genre)) { + details.genres.push(genre); + } + }); + + // Summary - WTR-Lab has the summary in a specific section + // Look for the summary text after "Novel Summary" or in the main content area + let summaryParts = []; + + // Try to find summary paragraphs + $('.summary p, .description p, .novel-summary p').each((_, el) => { + const text = $(el).text().trim(); + if (text && text.length > 20) { + summaryParts.push(text); + } + }); + + // If no summary found with selectors, try to extract from the page + if (summaryParts.length === 0) { + // Look for text after "Novel Summary" heading + const pageText = $('body').text(); + const summaryStart = pageText.indexOf('Novel Summary'); + if (summaryStart !== -1) { + const afterSummary = pageText.substring(summaryStart + 14).trim(); + // Get the first few paragraphs + const lines = afterSummary.split('\n').filter(l => l.trim().length > 20); + summaryParts = lines.slice(0, 5); + } + } + + details.summary = summaryParts.join('\n\n'); + + // Rankings - WTR-Lab shows ranking badges + const rankings = []; + $('a[href*="/ranking/"]').each((_, el) => { + const rankText = $(el).text().trim(); + if (rankText) { + rankings.push(rankText); + } + }); + if (rankings.length > 0) { + details.rankings = rankings; + } + + console.log('[parseWTRLabDetails] Parsed details:', { + title: details.title?.substring(0, 30), + author: details.author, + status: details.status, + chapters: details.chapters, + genres: details.genres.length, + hasCoverImage: !!details.imgSrc, + }); + + return details; +}; + +export default { + parseNovelDetails, + parseNovelGenres, + parseChapterList, + parseNovelPage, + parseWTRLabDetails, +}; diff --git a/src/Redux/Actions/parsers/novelHomeParser.js b/src/Redux/Actions/parsers/novelHomeParser.js new file mode 100644 index 00000000..97431b55 --- /dev/null +++ b/src/Redux/Actions/parsers/novelHomeParser.js @@ -0,0 +1,323 @@ +/** + * Novel home page parsing utilities + * Generic parser for extracting novel sections and cards from home pages + */ + +import {normalizeImageUrl} from '../utils/dataHelpers'; + +/** + * Parses novel home page sections using configuration + * + * @param {Object} $ - Cheerio instance + * @param {Object} config - Parser configuration + * @param {Object} options - Additional options (baseUrl, etc.) + * @returns {Array} - Array of sections with novels + */ +export const parseNovelHomeSections = ($, config, options = {}) => { + const {baseUrl = ''} = options; + const sections = []; + + if (!config || !config.sections) { + return sections; + } + + // Parse each configured section + config.sections.forEach(sectionConfig => { + const novels = extractNovelCards($, sectionConfig, {baseUrl}); + + if (novels.length > 0) { + sections.push({ + name: sectionConfig.name, + novels, + }); + } + }); + + return sections; +}; + +/** + * Extracts novel cards from a section + * + * @param {Object} $ - Cheerio instance + * @param {Object} sectionConfig - Section configuration + * @param {Object} options - Additional options + * @returns {Array} - Array of parsed novel cards + */ +export const extractNovelCards = ($, sectionConfig, options = {}) => { + const novels = []; + const {baseUrl = ''} = options; + + if (!sectionConfig.cardSelector) { + return novels; + } + + $(sectionConfig.cardSelector).each((index, element) => { + const novel = parseNovelCard($, element, { + ...sectionConfig, + baseUrl, + }); + + if (novel?.title && novel?.link) { + novels.push(novel); + } + }); + + return novels; +}; + +/** + * Parses an individual novel card element + * + * @param {Object} $ - Cheerio instance + * @param {Object} element - Cheerio element + * @param {Object} cardConfig - Card parsing configuration + * @returns {Object|null} - Parsed novel data or null + */ +export const parseNovelCard = ($, element, cardConfig) => { + const $el = $(element); + const { + titleSelector, + linkSelector, + imageSelector, + imageAttr = 'src', + authorSelector, + ratingSelector, + chaptersSelector, + statusSelector, + baseUrl = '', + linkAttr = 'href', + } = cardConfig; + + // Extract title + const title = titleSelector + ? $el.find(titleSelector).text().trim() || $el.find(titleSelector).attr('title') + : $el.text().trim(); + + // Extract link + let link = linkSelector + ? $el.find(linkSelector).attr(linkAttr) + : $el.attr(linkAttr); + + // Normalize link + if (link) { + link = normalizeLink(link, baseUrl); + } + + // Extract cover image + let coverImage = null; + if (imageSelector) { + const $img = $el.find(imageSelector); + // Try data-src first (lazy loading), then src + coverImage = $img.attr('data-src') || $img.attr(imageAttr); + coverImage = normalizeImageUrl(coverImage); + + // Handle relative URLs + if (coverImage && !coverImage.startsWith('http')) { + coverImage = baseUrl + (coverImage.startsWith('/') ? '' : '/') + coverImage; + } + } + + // Extract author + const author = authorSelector + ? $el.find(authorSelector).text().trim() + : null; + + // Extract rating + let rating = null; + if (ratingSelector) { + const ratingText = $el.find(ratingSelector).text().trim(); + const ratingMatch = ratingText.match(/(\d+\.?\d*)/); + if (ratingMatch) { + rating = parseFloat(ratingMatch[1]); + } + } + + // Extract chapters count + let chapters = null; + if (chaptersSelector) { + const chaptersText = $el.find(chaptersSelector).text().trim(); + const chaptersMatch = chaptersText.match(/(\d+)/); + if (chaptersMatch) { + chapters = parseInt(chaptersMatch[1], 10); + } + } + + // Extract status + const status = statusSelector + ? $el.find(statusSelector).text().trim() || 'Ongoing' + : 'Ongoing'; + + return { + title, + link, + coverImage, + author, + rating, + chapters, + status, + }; +}; + +/** + * Normalizes a link URL + * + * @param {string} link - The link to normalize + * @param {string} baseUrl - The base URL + * @returns {string} - Normalized link + */ +const normalizeLink = (link, baseUrl) => { + if (!link) { + return link; + } + + // Already absolute URL + if (link.startsWith('http')) { + return link; + } + + // Handle query string URLs (e.g., /?page=...) + if (link.startsWith('/?')) { + return baseUrl + link.substring(1); + } + + // Relative URL + if (link.startsWith('/')) { + return baseUrl + link; + } + + return baseUrl + '/' + link; +}; + +/** + * Default NovelFire.net home page configuration + */ +export const NOVELFIRE_HOME_CONFIG = { + sections: [ + { + name: 'Recommends', + containerSelector: 'section:has(h3:contains("Recommends"))', + cardSelector: 'li.novel-item', + titleSelector: 'a[title]', + linkSelector: 'a[href*="/book/"]', + imageSelector: 'img', + imageAttr: 'src', + authorSelector: '.author', + ratingSelector: '.badge._br', + chaptersSelector: '.chapters', + statusSelector: '.status', + }, + { + name: 'Most Read', + containerSelector: '.ranking-section', + cardSelector: 'li.novel-item', + titleSelector: 'a[title]', + linkSelector: 'a[href*="/book/"]', + imageSelector: 'img', + imageAttr: 'src', + authorSelector: '.author', + ratingSelector: '.badge._br', + chaptersSelector: '.chapters', + statusSelector: '.status', + }, + { + name: 'Latest Novels', + containerSelector: 'section:has(h3:contains("Latest Novels"))', + cardSelector: 'li.novel-item', + titleSelector: 'a[title]', + linkSelector: 'a[href*="/book/"]', + imageSelector: 'img', + imageAttr: 'src', + authorSelector: '.author', + ratingSelector: '.badge._br', + chaptersSelector: '.chapters', + statusSelector: '.status', + }, + { + name: 'Completed Stories', + containerSelector: 'section:has(h3:contains("Completed Stories"))', + cardSelector: 'li.novel-item', + titleSelector: 'a[title]', + linkSelector: 'a[href*="/book/"]', + imageSelector: 'img', + imageAttr: 'src', + authorSelector: '.author', + ratingSelector: '.badge._br', + chaptersSelector: '.chapters', + statusSelector: '.status', + }, + ], +}; + +/** + * Parse NovelFire home page with default configuration + * + * @param {Object} $ - Cheerio instance + * @returns {Array} - Array of sections with novels + */ +export const parseNovelFireHome = $ => { + return parseNovelHomeSections($, NOVELFIRE_HOME_CONFIG, { + baseUrl: 'https://novelfire.net', + }); +}; + +/** + * WTR-Lab home page configuration + */ +export const WTRLAB_HOME_CONFIG = { + sourceKey: 'wtrlab', + baseUrl: 'https://wtr-lab.com', + sections: [ + { + name: 'New Novels', + url: '/en/novel-list', + cardSelector: '.novel-item, a[href*="/novel/"]', + titleSelector: '.novel-title, h3, h4', + linkSelector: 'a[href*="/novel/"]', + imageSelector: 'img', + imageAttr: 'src', + authorSelector: '.author', + ratingSelector: '.rating', + chaptersSelector: '.chapters, span:contains("Chapters")', + statusSelector: '.status', + }, + { + name: 'Trending', + url: '/en/trending', + cardSelector: '.novel-item, a[href*="/novel/"]', + titleSelector: '.novel-title, h3, h4', + linkSelector: 'a[href*="/novel/"]', + imageSelector: 'img', + imageAttr: 'src', + authorSelector: '.author', + ratingSelector: '.rating', + chaptersSelector: '.chapters, span:contains("Chapters")', + statusSelector: '.status', + }, + { + name: 'Ranking', + url: '/en/ranking/daily', + cardSelector: 'tr, .ranking-item', + titleSelector: 'a[href*="/novel/"]', + linkSelector: 'a[href*="/novel/"]', + imageSelector: 'img', + imageAttr: 'src', + authorSelector: '.author', + ratingSelector: '.rating', + chaptersSelector: '.chapters', + statusSelector: '.status', + }, + ], +}; + +/** + * Parse WTR-Lab home page + * + * @param {Object} $ - Cheerio instance + * @returns {Array} - Array of sections with novels + */ +export const parseWTRLabHome = $ => { + return parseNovelHomeSections($, WTRLAB_HOME_CONFIG, { + baseUrl: 'https://wtr-lab.com', + }); +}; diff --git a/src/Redux/Actions/utils/errorHandlers.js b/src/Redux/Actions/utils/errorHandlers.js index fbd27f75..fd3111b7 100644 --- a/src/Redux/Actions/utils/errorHandlers.js +++ b/src/Redux/Actions/utils/errorHandlers.js @@ -2,7 +2,14 @@ * Error handling utilities for Redux actions */ -import {StopLoading, DownTime, fetchDataFailure} from '../../Reducers'; +import {StopLoading, DownTime, fetchDataFailure, setSourceStatusNotification} from '../../Reducers'; +import { + recordSourceError, + recordSourceSuccess, + getStatusFromCode, + SOURCE_STATUS, + getSourceLabel, +} from '../../../Utils/sourceStatus'; // Error message templates export const ERROR_MESSAGES = { @@ -11,6 +18,7 @@ export const ERROR_MESSAGES = { NOT_FOUND: 'Oops!! Looks like the comic is not available right now,\nPlease try again later...', ANIME_NOT_FOUND: 'Oops!! Looks like the anime episode is not available right now,\nPlease try again later...', GENERAL_ERROR: 'Oops!! something went wrong, please try again...', + CLOUDFLARE_PROTECTED: 'This source is protected by Cloudflare bot detection.\nPlease try another source.', }; // HTTP status code ranges @@ -24,18 +32,39 @@ export const HTTP_STATUS = { * Checks for downtime based on the error response and dispatches appropriate actions. * * @param {Object} error - The error object received from the API call. + * @param {string} sourceKey - Optional source key to track which source had the error. * @returns {Function} - A function that dispatches actions based on the error status. */ -export const checkDownTime = error => async dispatch => { +export const checkDownTime = (error, sourceKey = null) => async dispatch => { dispatch(StopLoading()); if (!error) { dispatch(DownTime(false)); + if (sourceKey) { + recordSourceSuccess(sourceKey); + } return; } const statusCode = error?.response?.status; + // Track source-specific errors + if (sourceKey && statusCode) { + const sourceStatus = getStatusFromCode(statusCode); + recordSourceError(sourceKey, statusCode); + + // Dispatch notification for 403 or 500+ errors + if (sourceStatus === SOURCE_STATUS.CLOUDFLARE_PROTECTED || + sourceStatus === SOURCE_STATUS.SERVER_DOWN) { + dispatch(setSourceStatusNotification({ + sourceKey, + status: sourceStatus, + statusCode, + sourceName: getSourceLabel(sourceKey), + })); + } + } + // Server error (500+) if (statusCode >= HTTP_STATUS.SERVER_ERROR) { dispatch(DownTime(true)); @@ -52,9 +81,9 @@ export const checkDownTime = error => async dispatch => { return; } - // Forbidden error (403) + // Forbidden error (403) - Cloudflare protection if (statusCode === HTTP_STATUS.FORBIDDEN) { - dispatch(fetchDataFailure(ERROR_MESSAGES.GENERAL_ERROR)); + dispatch(fetchDataFailure(ERROR_MESSAGES.CLOUDFLARE_PROTECTED)); return; } @@ -72,9 +101,10 @@ export const checkDownTime = error => async dispatch => { * @param {Function} dispatch - Redux dispatch function * @param {Function} crashlytics - Firebase crashlytics instance * @param {string} context - Context of where error occurred + * @param {string} sourceKey - Optional source key to track which source had the error */ -export const handleAPIError = (error, dispatch, crashlytics, context = 'API call') => { +export const handleAPIError = (error, dispatch, crashlytics, context = 'API call', sourceKey = null) => { crashlytics().recordError(error); console.log(`Error in ${context}:`, error); - dispatch(checkDownTime(error)); + dispatch(checkDownTime(error, sourceKey)); }; diff --git a/src/Redux/Reducers/index.js b/src/Redux/Reducers/index.js index b1380b0b..29d7cdf6 100644 --- a/src/Redux/Reducers/index.js +++ b/src/Redux/Reducers/index.js @@ -41,6 +41,22 @@ const initialState = { hasSeenOfflineMovedAlert: false, hasSeenV146Walkthrough: false, localComicProgress: null, // {lastReadPage, totalPages} for locally imported comics + // Novel state + NovelBookMarks: {}, // {novelLink: {title, coverImage, link, author, ...}} + NovelHistory: {}, // {novelLink: {title, coverImage, link, lastChapter, lastReadAt, ...}} + novelReaderMode: 'text', // 'text' | 'webview' + novelReaderTheme: 'dark', // 'light' | 'dark' | 'sepia' + novelFontSize: 18, + novelLineHeight: 1.6, + novelFontFamily: 'serif', // 'serif' | 'sans-serif' | 'monospace' + // Novel source management + novelBaseUrl: 'novelfire', + novelSources: { + novelfire: { id: 'novelfire', name: 'NovelFire', enabled: true }, + wtrlab: { id: 'wtrlab', name: 'WTR-Lab', enabled: true }, + }, + // WTR-Lab reading mode preference + wtrlabReadingMode: 'web', // 'web' | 'webplus' | 'ai' // Community & Auth state user: null, // {uid, displayName, photoURL, email, subscriptionTier} communityPosts: {}, // {comicLink: {posts: [], lastFetch: timestamp}} @@ -49,6 +65,8 @@ const initialState = { userActivity: {}, // {postsToday: 0, repliesToday: 0, lastReset: date} notifications: [], // [{id,title,body,data,receivedAt,read}] notificationSubscriptions: {}, // { [uid]: { lastFetched, allowed, subscribedList: [] } } + // Source status notifications + sourceStatusNotifications: [], // [{id, sourceKey, sourceName, status, statusCode, timestamp, read}] }; /** @@ -286,6 +304,9 @@ const Reducers = createSlice({ state.baseUrl = action.payload; state.downTime = false; }, + switchNovelSource: (state, action) => { + state.novelBaseUrl = action.payload; + }, SwtichToAnime: state => { state.Anime = !state.Anime; state.downTime = false; @@ -362,6 +383,98 @@ const Reducers = createSlice({ totalPages, }; }, + // Novel reducers + AddNovelBookMark: (state, action) => { + state.NovelBookMarks[action?.payload?.link] = action?.payload; + }, + RemoveNovelBookMark: (state, action) => { + delete state.NovelBookMarks[action?.payload?.link]; + }, + clearNovelBookmarks: state => { + state.NovelBookMarks = {}; + }, + pushNovelHistory: (state, action) => { + const link = action.payload.link; + state.NovelHistory[link] = { + ...state.NovelHistory[link], + ...action.payload, + }; + }, + updateNovelHistory: (state, action) => { + const { novelLink, chapterLink, chapterNumber, chapterTitle, scrollProgress } = action.payload; + if (!novelLink || !chapterLink) return; + + const now = Date.now(); + const isCompleted = typeof scrollProgress === 'number' && scrollProgress >= 95; + + // Initialize chapterProgress if not exists + const existingProgress = state.NovelHistory[novelLink]?.chapterProgress || {}; + + state.NovelHistory[novelLink] = { + ...state.NovelHistory[novelLink], + lastChapter: chapterNumber, + lastChapterLink: chapterLink, + lastChapterTitle: chapterTitle, + lastReadAt: now, + // Per-chapter progress tracking + chapterProgress: { + ...existingProgress, + [chapterLink]: { + scrollProgress: scrollProgress ?? existingProgress[chapterLink]?.scrollProgress ?? 0, + completed: isCompleted || existingProgress[chapterLink]?.completed || false, + lastReadAt: now, + }, + }, + }; + }, + updateNovelChapterProgress: (state, action) => { + const { novelLink, chapterLink, scrollProgress } = action.payload; + if (!novelLink || !chapterLink) return; + + const now = Date.now(); + const isCompleted = typeof scrollProgress === 'number' && scrollProgress >= 95; + + // Initialize if novel not in history + if (!state.NovelHistory[novelLink]) { + state.NovelHistory[novelLink] = { + chapterProgress: {}, + }; + } + + // Initialize chapterProgress if not exists + if (!state.NovelHistory[novelLink].chapterProgress) { + state.NovelHistory[novelLink].chapterProgress = {}; + } + + const existingProgress = state.NovelHistory[novelLink].chapterProgress[chapterLink] || {}; + + state.NovelHistory[novelLink].chapterProgress[chapterLink] = { + scrollProgress: scrollProgress ?? existingProgress.scrollProgress ?? 0, + completed: isCompleted || existingProgress.completed || false, + lastReadAt: now, + }; + }, + clearNovelHistory: state => { + state.NovelHistory = {}; + }, + setNovelReaderMode: (state, action) => { + state.novelReaderMode = action.payload; + }, + setNovelReaderTheme: (state, action) => { + state.novelReaderTheme = action.payload; + }, + setNovelFontSize: (state, action) => { + state.novelFontSize = action.payload; + }, + setNovelLineHeight: (state, action) => { + state.novelLineHeight = action.payload; + }, + setNovelFontFamily: (state, action) => { + state.novelFontFamily = action.payload; + }, + setWtrlabReadingMode: (state, action) => { + state.wtrlabReadingMode = action.payload; + }, // Community & Auth reducers setUser: (state, action) => { state.user = action.payload; @@ -608,6 +721,66 @@ const Reducers = createSlice({ ); } }, + // Source status notification reducers + setSourceStatusNotification: (state, action) => { + const { sourceKey, status, statusCode, sourceName } = action.payload || {}; + if (!sourceKey || !status) { + return; + } + const now = Date.now(); + const notificationId = `source-${sourceKey}-${now}`; + + // Check if we already have a recent notification for this source (within 5 minutes) + const fiveMinutesAgo = now - 300000; + const recentNotification = state.sourceStatusNotifications.find( + n => n.sourceKey === sourceKey && n.timestamp > fiveMinutesAgo + ); + + if (recentNotification) { + // Update existing notification instead of creating new one + recentNotification.status = status; + recentNotification.statusCode = statusCode; + recentNotification.timestamp = now; + recentNotification.read = false; + return; + } + + // Add new notification + state.sourceStatusNotifications.unshift({ + id: notificationId, + sourceKey, + sourceName, + status, + statusCode, + timestamp: now, + read: false, + }); + + // Keep only last 20 notifications + state.sourceStatusNotifications = state.sourceStatusNotifications.slice(0, 20); + }, + markSourceStatusNotificationRead: (state, action) => { + const notificationId = action.payload; + if (!notificationId) { + return; + } + const notification = state.sourceStatusNotifications.find(n => n.id === notificationId); + if (notification) { + notification.read = true; + } + }, + clearSourceStatusNotifications: state => { + state.sourceStatusNotifications = []; + }, + removeSourceStatusNotification: (state, action) => { + const notificationId = action.payload; + if (!notificationId) { + return; + } + state.sourceStatusNotifications = state.sourceStatusNotifications.filter( + n => n.id !== notificationId + ); + }, }, }); @@ -623,6 +796,7 @@ export const { UpdateSearch, DownTime, SwtichBaseUrl, + switchNovelSource, SwtichToAnime, AnimeWatched, AddAnimeBookMark, @@ -645,6 +819,20 @@ export const { markV146WalkthroughSeen, clearLocalComicProgress, updateLocalComicProgress, + // Novel actions + AddNovelBookMark, + RemoveNovelBookMark, + clearNovelBookmarks, + pushNovelHistory, + updateNovelHistory, + updateNovelChapterProgress, + clearNovelHistory, + setNovelReaderMode, + setNovelReaderTheme, + setWtrlabReadingMode, + setNovelFontSize, + setNovelLineHeight, + setNovelFontFamily, // Community & Auth actions setUser, clearUser, @@ -660,5 +848,10 @@ export const { clearNotifications, setNotificationSubscriptionCache, updateNotificationSubscriptionList, + // Source status notification actions + setSourceStatusNotification, + markSourceStatusNotificationRead, + clearSourceStatusNotifications, + removeSourceStatusNotification, } = Reducers.actions; export default Reducers.reducer; diff --git a/src/Screens/Comic/APIs/Home.js b/src/Screens/Comic/APIs/Home.js index e115c450..65b0100b 100644 --- a/src/Screens/Comic/APIs/Home.js +++ b/src/Screens/Comic/APIs/Home.js @@ -7,8 +7,16 @@ import { extractLastPage, parseHomePageCards, } from './homeParser'; - -export const getComics = async (hostName, page, type = null) => { +import {checkDownTime} from '../../../Redux/Actions/GlobalActions'; +import { + recordSourceError, + recordSourceSuccess, + getStatusFromCode, + SOURCE_STATUS, + getSourceLabel, +} from '../../../Utils/sourceStatus'; + +export const getComics = async (hostName, page, type = null, dispatch = null) => { try { const hostKey = Object.keys(ComicHostName).find( key => ComicHostName[key] === hostName, @@ -34,55 +42,92 @@ export const getComics = async (hostName, page, type = null) => { } } + // Record successful request + if (hostKey) { + recordSourceSuccess(hostKey); + } + return {comicsData, lastPage}; } catch (error) { console.error('Error fetching comics data:', error); console.log('Request URL:', hostName, page, type); + // Track source-specific errors + const hostKey = Object.keys(ComicHostName).find( + key => ComicHostName[key] === hostName, + ); + const statusCode = error?.response?.status; + + if (hostKey && statusCode) { + recordSourceError(hostKey, statusCode); + + // Dispatch notification for 403 or 500+ errors + const sourceStatus = getStatusFromCode(statusCode); + if ((sourceStatus === SOURCE_STATUS.CLOUDFLARE_PROTECTED || + sourceStatus === SOURCE_STATUS.SERVER_DOWN) && dispatch) { + const {setSourceStatusNotification} = require('../../../Redux/Reducers'); + dispatch(setSourceStatusNotification({ + sourceKey: hostKey, + status: sourceStatus, + statusCode, + sourceName: getSourceLabel(hostKey), + })); + } + } + + // Also dispatch checkDownTime for general error handling + if (dispatch) { + dispatch(checkDownTime(error, hostKey)); + } + return null; } }; -const HomeType = { +const getHomeRequests = (type, dispatch) => ({ readcomicsonline: { hot_comic_updates: getComics( ComicHostName.readcomicsonline, 1, 'hot-comic-updates', + dispatch, ), latest_release: getComics( ComicHostName.readcomicsonline, 1, 'latest-release', + dispatch, ), - most_viewed: getComics(ComicHostName.readcomicsonline, 1, 'most-viewed'), + most_viewed: getComics(ComicHostName.readcomicsonline, 1, 'most-viewed', dispatch), }, comichubfree: { - hot_comic_updates: getComics(ComicHostName.comichubfree, 1, 'hot-comic'), - latest_release: getComics(ComicHostName.comichubfree, 1, 'new-comic'), - most_viewed: getComics(ComicHostName.comichubfree, 1, 'popular-comic'), - all_comic: getComics(ComicHostName.comichubfree, 1), + hot_comic_updates: getComics(ComicHostName.comichubfree, 1, 'hot-comic', dispatch), + latest_release: getComics(ComicHostName.comichubfree, 1, 'new-comic', dispatch), + most_viewed: getComics(ComicHostName.comichubfree, 1, 'popular-comic', dispatch), + all_comic: getComics(ComicHostName.comichubfree, 1, null, dispatch), }, readallcomics: { - all_comic: getComics(ComicHostName.readallcomics, 1), + all_comic: getComics(ComicHostName.readallcomics, 1, null, dispatch), }, comicbookplus: { latest_release: getComics( ComicHostName.comicbookplus, 0, 'latest-uploads', + dispatch, ), }, -}; +}); export const getComicsHome = async ( type = 'comichubfree', setComics, setLoading, + dispatch = null, ) => { setLoading(true); try { - const requests = HomeType[type] || {}; + const requests = getHomeRequests(type, dispatch)[type] || {}; const entries = await Promise.all( Object.entries(requests).map(async ([key, promise]) => [key, await promise]), ); diff --git a/src/Screens/Comic/Bookmarks/NovelBookmarks.js b/src/Screens/Comic/Bookmarks/NovelBookmarks.js new file mode 100644 index 00000000..9e44062c --- /dev/null +++ b/src/Screens/Comic/Bookmarks/NovelBookmarks.js @@ -0,0 +1,285 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + FlatList, + Alert, +} from 'react-native'; + +import {useDispatch, useSelector} from 'react-redux'; +import FontAwesome6 from 'react-native-vector-icons/FontAwesome6'; +import crashlytics from '@react-native-firebase/crashlytics'; +import analytics from '@react-native-firebase/analytics'; +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; +import Ionicons from 'react-native-vector-icons/Ionicons'; +import {heightPercentageToDP} from 'react-native-responsive-screen'; + +import {RemoveNovelBookMark} from '../../../Redux/Reducers'; +import Image from '../../../Components/UIComp/Image'; +import {NAVIGATION} from '../../../Constants'; + +const getBookmarkColor = index => { + const colors = [ + '#9C27B0', + '#E040FB', + '#7C4DFF', + '#EA80FC', + '#B388FF', + '#AA00FF', + ]; + return colors[index % colors.length]; +}; + +const EmptyBookmarks = () => ( + + + + + No Novel Bookmarks + + Save your favorite novels to access them quickly + + +); + +export function NovelBookmarks({navigation}) { + const dispatch = useDispatch(); + const novelBookmarks = useSelector( + state => state.data.NovelBookMarks || {}, + ); + const bookmarks = Object.values(novelBookmarks); + + const renderItem = ({item, index}) => { + const color = getBookmarkColor(index); + + return ( + { + crashlytics().log('Novel Bookmark clicked'); + analytics().logEvent('Novel_Bookmark_clicked', { + title: item?.title?.toString(), + link: item?.link?.toString(), + }); + navigation.navigate(NAVIGATION.novelDetails, { + novel: item, + }); + }}> + {/* Color accent */} + + + {/* Novel Cover */} + + + {/* Content */} + + + + Novel + + + {item.title} + + + {item?.author && ( + + + + {item.author} + + + )} + + {item?.status && ( + + + + {item.status} + + + )} + + {item?.chapters > 0 && ( + + + {item.chapters} Chapters + + + )} + + + {/* Remove bookmark button */} + { + Alert.alert( + 'Remove Bookmark', + `Remove "${item.title}" from bookmarks?`, + [ + {text: 'Cancel', style: 'cancel'}, + { + text: 'Remove', + style: 'destructive', + onPress: () => { + crashlytics().log('Novel Bookmark removed'); + analytics().logEvent('Novel_Bookmark_removed', { + link: item?.link?.toString(), + }); + dispatch(RemoveNovelBookMark({link: item.link})); + }, + }, + ], + ); + }}> + + + + ); + }; + + return ( + + item?.link || index.toString()} + renderItem={renderItem} + contentContainerStyle={styles.listContent} + ListEmptyComponent={EmptyBookmarks} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#14142A', + }, + listContent: { + padding: 12, + paddingBottom: 24, + }, + card: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(255,255,255,0.03)', + borderRadius: 16, + padding: 12, + marginBottom: 10, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.06)', + overflow: 'hidden', + }, + colorAccent: { + position: 'absolute', + left: 0, + top: 0, + bottom: 0, + width: 4, + }, + image: { + width: 75, + height: 100, + borderRadius: 10, + marginLeft: 8, + }, + content: { + flex: 1, + marginLeft: 14, + justifyContent: 'center', + }, + novelBadge: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + alignSelf: 'flex-start', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + marginBottom: 6, + }, + novelBadgeText: { + fontSize: 10, + fontWeight: '700', + }, + title: { + color: '#FFF', + fontSize: 15, + fontWeight: '700', + marginBottom: 6, + lineHeight: 20, + }, + metaRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginBottom: 4, + }, + metaText: { + color: 'rgba(255,255,255,0.5)', + fontSize: 12, + flex: 1, + }, + chapterBadge: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 4, + alignSelf: 'flex-start', + marginTop: 4, + }, + chapterText: { + fontSize: 10, + fontWeight: '700', + }, + bookmarkButton: { + width: 44, + height: 44, + borderRadius: 12, + justifyContent: 'center', + alignItems: 'center', + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingTop: heightPercentageToDP('20%'), + paddingHorizontal: 32, + }, + emptyIcon: { + width: 80, + height: 80, + borderRadius: 24, + backgroundColor: 'rgba(156, 39, 176, 0.1)', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 20, + }, + emptyTitle: { + color: '#FFF', + fontSize: 20, + fontWeight: '700', + marginBottom: 8, + }, + emptySubtitle: { + color: 'rgba(255,255,255,0.5)', + fontSize: 14, + textAlign: 'center', + }, +}); + +export default NovelBookmarks; diff --git a/src/Screens/Comic/Bookmarks/index.js b/src/Screens/Comic/Bookmarks/index.js index 1471384c..3bab27b0 100644 --- a/src/Screens/Comic/Bookmarks/index.js +++ b/src/Screens/Comic/Bookmarks/index.js @@ -9,9 +9,11 @@ import { import {useSelector} from 'react-redux'; import {Bookmarks} from './Bookmarks'; import {MangaBookmarks} from './MangaBookmarks'; +import {NovelBookmarks} from './NovelBookmarks'; import Header from '../../../Components/UIComp/Header'; import {goBack} from '../../../Navigation/NavigationService'; import Ionicons from 'react-native-vector-icons/Ionicons'; +import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import {heightPercentageToDP} from 'react-native-responsive-screen'; export function ComicBookmarks({navigation}) { @@ -23,7 +25,10 @@ export function ComicBookmarks({navigation}) { const mangaBookMarksLength = useSelector( state => Object.keys(state.data.MangaBookMarks || {}).length, ); - const totalCount = bookMarksLength + mangaBookMarksLength; + const novelBookMarksLength = useSelector( + state => Object.keys(state.data.NovelBookMarks || {}).length, + ); + const totalCount = bookMarksLength + mangaBookMarksLength + novelBookMarksLength; return ( @@ -66,6 +71,11 @@ export function ComicBookmarks({navigation}) { setActiveTab('comic')}> + setActiveTab('manga')}> + Manga {mangaBookMarksLength > 0 && ( - - + + {mangaBookMarksLength} )} + setActiveTab('novel')}> + + + Novels + + {novelBookMarksLength > 0 && ( + + + {novelBookMarksLength} + + + )} + @@ -106,10 +144,14 @@ export function ComicBookmarks({navigation}) { - ) : ( + ) : activeTab === 'manga' ? ( + ) : ( + )} @@ -142,14 +184,26 @@ const styles = StyleSheet.create({ activeTab: { backgroundColor: 'rgba(102, 126, 234, 0.2)', }, + activeTabManga: { + backgroundColor: 'rgba(0, 122, 255, 0.2)', + }, + activeTabNovel: { + backgroundColor: 'rgba(156, 39, 176, 0.2)', + }, tabText: { fontWeight: '600', - fontSize: 13, + fontSize: 12, color: 'rgba(255,255,255,0.4)', }, activeTabText: { color: '#667EEA', }, + activeTabMangaText: { + color: '#007AFF', + }, + activeTabNovelText: { + color: '#9C27B0', + }, countBadge: { minWidth: 28, height: 28, @@ -175,6 +229,12 @@ const styles = StyleSheet.create({ tabBadgeActive: { backgroundColor: 'rgba(102, 126, 234, 0.3)', }, + tabBadgeMangaActive: { + backgroundColor: 'rgba(0, 122, 255, 0.3)', + }, + tabBadgeNovelActive: { + backgroundColor: 'rgba(156, 39, 176, 0.3)', + }, tabBadgeText: { fontSize: 11, fontWeight: '700', @@ -183,4 +243,10 @@ const styles = StyleSheet.create({ tabBadgeTextActive: { color: '#667EEA', }, + tabBadgeMangaTextActive: { + color: '#007AFF', + }, + tabBadgeNovelTextActive: { + color: '#9C27B0', + }, }); diff --git a/src/Screens/Comic/Library/index.js b/src/Screens/Comic/Library/index.js index dd270c07..585540ff 100644 --- a/src/Screens/Comic/Library/index.js +++ b/src/Screens/Comic/Library/index.js @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useState, useMemo} from 'react'; import { View, StyleSheet, @@ -26,11 +26,14 @@ import {NAVIGATION} from '../../../Constants'; import {useSelector, useDispatch} from 'react-redux'; import {getComicsHome} from '../APIs/Home'; import {getMangaHome} from '../../../InkNest-Externals/Screens/Manga/APIs'; +import {getNovelHome} from '../../Novel/APIs'; import HistoryCard from './Components/HistoryCard'; import MangaHistoryCard from './Components/MangaHistoryCard'; import AnimeAdbanner from '../../../Components/UIComp/AnimeAdBanner/AnimeAdbanner'; import {clearHistory, clearMangaHistory} from '../../../Redux/Reducers'; -import {ComicHostName} from '../../../Utils/APIs'; +import {ComicHostName, NovelHostName} from '../../../Utils/APIs'; +import {NovelSectionList} from '../../Novel/Components/NovelList'; +import {getSourceLabel} from '../../../Utils/sourceStatus'; export function Library({navigation}) { const [comicsData, setComicsData] = useState({}); @@ -39,13 +42,19 @@ export function Library({navigation}) { manga: null, newest: null, }); + const [novelSections, setNovelSections] = useState([]); const [loading, setLoading] = useState(false); const [mangaLoading, setMangaLoading] = useState(false); + const [novelLoading, setNovelLoading] = useState(false); const [type, setType] = useState('readcomicsonline'); const [changeType, setChangeType] = useState(false); const [activeTab, setActiveTab] = useState('comic'); + const [changeNovelSource, setChangeNovelSource] = useState(false); const History = useSelector(state => state.data.history); const MangaHistory = useSelector(state => state.data.MangaHistory || {}); + const NovelHistory = useSelector(state => state.data.NovelHistory || {}); + const novelBaseUrl = useSelector(state => state.data.novelBaseUrl || 'novelfire'); + const novelSources = useSelector(state => state.data.novelSources || {}); const comicBookmarkCount = useSelector( state => Object.values(state.data.dataByUrl).filter(item => item.Bookmark).length, @@ -53,11 +62,21 @@ export function Library({navigation}) { const mangaBookmarkCount = useSelector( state => Object.keys(state.data.MangaBookMarks || {}).length, ); - const totalBookmarkCount = comicBookmarkCount + mangaBookmarkCount; + const novelBookmarkCount = useSelector( + state => Object.keys(state.data.NovelBookMarks || {}).length, + ); + const totalBookmarkCount = comicBookmarkCount + mangaBookmarkCount + novelBookmarkCount; const notifications = useSelector(state => state.data?.notifications || []); - const hasUnreadNotifications = notifications.some( - notification => !notification?.read, + const sourceStatusNotifications = useSelector( + state => state.data?.sourceStatusNotifications || [], ); + const hasUnreadNotifications = useMemo(() => { + const hasUnreadRegular = notifications.some(notification => !notification?.read); + const hasUnreadSourceStatus = sourceStatusNotifications.some( + notification => !notification?.read, + ); + return hasUnreadRegular || hasUnreadSourceStatus; + }, [notifications, sourceStatusNotifications]); const dispatch = useDispatch(); const {value: forIosValue, loading: forIosLoading} = useFeatureFlag( 'forIos', @@ -110,12 +129,19 @@ export function Library({navigation}) { }); } else { if (forIosLoading === false) { - getComicsHome(type, setComicsData, setLoading); - getMangaHome(setMangaData, setMangaLoading); + getComicsHome(type, setComicsData, setLoading, dispatch); + getMangaHome(setMangaData, setMangaLoading, dispatch); + getNovelHome(novelBaseUrl).then(data => { + setNovelSections(data || []); + setNovelLoading(false); + }).catch(err => { + console.error('Error fetching novel home:', err); + setNovelLoading(false); + }); } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [forIosValue, forIosLoading]); + }, [forIosValue, forIosLoading, novelBaseUrl]); const allSections = Object.entries(comicsData); const isSpecialBuild = @@ -165,6 +191,24 @@ export function Library({navigation}) { Manga + setActiveTab('novel')}> + + + Novels + + @@ -264,7 +308,7 @@ export function Library({navigation}) { hostName: key, }); setType(key); - getComicsHome(key, setComicsData, setLoading); + getComicsHome(key, setComicsData, setLoading, dispatch); setChangeType(false); }}> {type === key ? ( @@ -571,6 +615,156 @@ export function Library({navigation}) { )} + {/* ===== NOVEL TAB ===== */} + {activeTab === 'novel' && ( + <> + {/* Novel Source Selector */} + {!isSpecialBuild && forIosLoading === false && ( + + { + setChangeNovelSource(!changeNovelSource); + crashlytics().log('Novel Source Name Clicked'); + analytics().logEvent('novel_source_name_clicked', { + source: novelBaseUrl.toString(), + }); + }} + style={styles.hostSelector}> + + {getSourceLabel(novelBaseUrl)} + + + + + )} + + {/* Novel Source dropdown */} + {changeNovelSource && ( + + {Object.keys(NovelHostName).map((key, index) => ( + { + crashlytics().log('Novel Source Selected'); + analytics().logEvent('novel_source_selected', { + source: key, + }); + setNovelSections([]); // Clear old sections + setNovelLoading(true); // Show loading when switching sources + dispatch({type: 'data/switchNovelSource', payload: key}); + setChangeNovelSource(false); + }}> + {novelBaseUrl === key ? ( + + ) : ( + + )} + + {getSourceLabel(key)} + + {NovelHostName[key]} + + + + ))} + + )} + + {/* Novel Continue Reading */} + {Object.values(NovelHistory).length > 0 && ( + + + Continue Reading + { + Alert.alert( + 'Clear Novel History', + 'Are you sure you want to clear your novel reading history?', + [ + {text: 'Cancel', style: 'cancel'}, + { + text: 'Clear', + onPress: () => dispatch({type: 'data/clearNovelHistory'}), + }, + ], + {cancelable: false}, + ); + }}> + Clear + + + (b.lastReadAt || 0) - (a.lastReadAt || 0), + )} + keyExtractor={(item, index) => `novel-history-${index}`} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.horizontalList} + renderItem={({item}) => ( + { + crashlytics().log('Novel history card clicked'); + navigation.navigate(NAVIGATION.novelDetails, { + novel: item, + }); + }}> + + + {item.title} + + {item.lastChapter && ( + + Ch. {item.lastChapter} + + )} + + )} + /> + + )} + + {/* Novel sections */} + {!novelLoading && novelSections.length > 0 && ( + { + crashlytics().log('Novel card clicked from Library'); + navigation.navigate(NAVIGATION.novelDetails, {novel}); + }} + onSeeAllPress={(sectionName, novels) => { + navigation.navigate(NAVIGATION.novelViewAll, { + title: sectionName, + novels, + }); + }} + style={styles.novelListContent} + /> + )} + + {/* Novel Loading */} + {novelLoading && ( + + + + )} + + )} + @@ -646,6 +840,9 @@ const styles = StyleSheet.create({ activeTabMangaText: { color: '#007AFF', }, + activeTabNovelText: { + color: '#9C27B0', + }, bookmarkBadge: { position: 'absolute', top: 2, @@ -776,6 +973,17 @@ const styles = StyleSheet.create({ marginTop: 2, }, + // Novel specific styles + novelChapterText: { + fontSize: 10, + color: '#9C27B0', + paddingHorizontal: 8, + paddingBottom: 6, + }, + novelListContent: { + paddingTop: 16, + }, + // Loading loadingContainer: { paddingVertical: 40, diff --git a/src/Screens/Notifications/NotificationsScreen.js b/src/Screens/Notifications/NotificationsScreen.js index e01fe242..e441937d 100644 --- a/src/Screens/Notifications/NotificationsScreen.js +++ b/src/Screens/Notifications/NotificationsScreen.js @@ -11,15 +11,24 @@ import { import {useDispatch, useSelector} from 'react-redux'; import crashlytics from '@react-native-firebase/crashlytics'; import Ionicons from 'react-native-vector-icons/Ionicons'; +import MaterialCommunityIcons from 'react-native-vector-icons/MaterialCommunityIcons'; import { heightPercentageToDP as hp, widthPercentageToDP as wp, } from 'react-native-responsive-screen'; import {NAVIGATION} from '../../Constants'; -import {markNotificationAsRead} from '../../Redux/Reducers'; +import { + markNotificationAsRead, + markSourceStatusNotificationRead, + removeSourceStatusNotification, +} from '../../Redux/Reducers'; import {SafeAreaView} from 'react-native-safe-area-context'; import {fetchComicDetails} from '../../Redux/Actions/GlobalActions'; +import { + SOURCE_STATUS, + formatLastChecked, +} from '../../Utils/sourceStatus'; const formatTimestamp = ts => { if (!ts) { @@ -55,6 +64,103 @@ const formatRelativeTime = ts => { const normalizeLink = link => (link ? link.split('?')[0] : ''); +const SourceStatusCard = ({item, onDismiss}) => { + const getStatusConfig = () => { + switch (item.status) { + case SOURCE_STATUS.CLOUDFLARE_PROTECTED: + return { + icon: 'shield-alert', + color: '#FF9500', + bgColor: 'rgba(255, 149, 0, 0.15)', + borderColor: 'rgba(255, 149, 0, 0.4)', + title: 'Cloudflare Protection', + description: `${item.sourceName} is blocking requests due to cloudflare bot protection.`, + }; + case SOURCE_STATUS.SERVER_DOWN: + return { + icon: 'server-off', + color: '#FF3B30', + bgColor: 'rgba(255, 59, 48, 0.15)', + borderColor: 'rgba(255, 59, 48, 0.4)', + title: 'Server Down', + description: `${item.sourceName} server is not responding.`, + }; + default: + return { + icon: 'alert-circle', + color: '#8E8E93', + bgColor: 'rgba(142, 142, 147, 0.15)', + borderColor: 'rgba(142, 142, 147, 0.4)', + title: 'Source Error', + description: `${item.sourceName} is experiencing issues.`, + }; + } + }; + + const config = getStatusConfig(); + + return ( + + + + + + + + {config.title} + + {item.sourceName} + + {!item.read ? ( + + NEW + + ) : null} + onDismiss(item.id)}> + + + + + {config.description} + + + + + + {formatLastChecked(item.timestamp)} + + + {item.statusCode && ( + + + + Error {item.statusCode} + + + )} + + + ); +}; + const NotificationCard = ({item, comicMeta, onPress}) => { const showComicMeta = Boolean(comicMeta?.title || comicMeta?.imgSrc); const coverImage = @@ -135,6 +241,9 @@ const NotificationCard = ({item, comicMeta, onPress}) => { const NotificationsScreen = ({navigation}) => { const dispatch = useDispatch(); const notifications = useSelector(state => state.data.notifications || []); + const sourceStatusNotifications = useSelector( + state => state.data.sourceStatusNotifications || [], + ); const dataByUrl = useSelector(state => state.data.dataByUrl || {}); const enhancedNotifications = useMemo(() => { @@ -151,13 +260,32 @@ const NotificationsScreen = ({navigation}) => { return { ...notification, comicMeta, + type: 'community', }; }); }, [dataByUrl, notifications]); + // Combine regular notifications with source status notifications + const allNotifications = useMemo(() => { + const sourceNotifications = sourceStatusNotifications.map(item => ({ + ...item, + type: 'source_status', + })); + + // Sort by timestamp (newest first) + const combined = [...enhancedNotifications, ...sourceNotifications]; + combined.sort((a, b) => (b.timestamp || b.receivedAt) - (a.timestamp || a.receivedAt)); + + return combined; + }, [enhancedNotifications, sourceStatusNotifications]); + const unreadCount = useMemo(() => { - return enhancedNotifications.filter(item => !item.read).length; - }, [enhancedNotifications]); + return allNotifications.filter(item => !item.read).length; + }, [allNotifications]); + + const sourceStatusUnreadCount = useMemo(() => { + return sourceStatusNotifications.filter(item => !item.read).length; + }, [sourceStatusNotifications]); const handleNavigationForNotification = useCallback(item => { if (!item?.data) { @@ -237,28 +365,49 @@ const NotificationsScreen = ({navigation}) => { ); const handleMarkAllRead = useCallback(() => { - enhancedNotifications.forEach(notification => { + allNotifications.forEach(notification => { if (!notification.read) { - dispatch(markNotificationAsRead(notification.id)); + if (notification.type === 'source_status') { + dispatch(markSourceStatusNotificationRead(notification.id)); + } else { + dispatch(markNotificationAsRead(notification.id)); + } } }); - }, [dispatch, enhancedNotifications]); + }, [dispatch, allNotifications]); + + const handleDismissSourceStatus = useCallback( + notificationId => { + dispatch(removeSourceStatusNotification(notificationId)); + }, + [dispatch], + ); const renderItem = useCallback( - ({item}) => ( - handlePress(item)} - /> - ), - [handlePress], + ({item}) => { + if (item.type === 'source_status') { + return ( + + ); + } + return ( + handlePress(item)} + /> + ); + }, + [handlePress, handleDismissSourceStatus], ); return ( item.id} renderItem={renderItem} ListHeaderComponent={ @@ -279,6 +428,11 @@ const NotificationsScreen = ({navigation}) => { {unreadCount > 0 ? `${unreadCount} unread` : 'All caught up'} + {sourceStatusUnreadCount > 0 && ( + + {sourceStatusUnreadCount} source issue{sourceStatusUnreadCount > 1 ? 's' : ''} need attention + + )} {unreadCount > 0 ? ( @@ -489,4 +643,75 @@ const styles = StyleSheet.create({ fontSize: hp('1.8%'), marginTop: hp('1%'), }, + // Source status styles + sourceStatusHint: { + color: '#FF9500', + fontSize: 11, + marginTop: 4, + }, + sourceStatusCard: { + borderRadius: 14, + padding: wp('4%'), + marginHorizontal: wp('6%'), + marginVertical: hp('1%'), + borderWidth: 1, + shadowColor: '#000', + shadowOpacity: 0.25, + shadowRadius: 8, + shadowOffset: {width: 0, height: 4}, + elevation: 4, + }, + sourceStatusCardUnread: { + borderWidth: 2, + }, + sourceStatusHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: hp('1%'), + }, + sourceStatusIconContainer: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.1)', + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + sourceStatusTitleGroup: { + flex: 1, + }, + sourceStatusTitle: { + fontSize: hp('1.8%'), + fontWeight: '700', + }, + sourceStatusSourceName: { + color: 'rgba(255,255,255,0.7)', + fontSize: hp('1.5%'), + marginTop: 2, + }, + dismissButton: { + padding: 8, + marginLeft: 8, + }, + sourceStatusDescription: { + color: 'rgba(255,255,255,0.85)', + fontSize: hp('1.7%'), + lineHeight: hp('2.4%'), + marginBottom: hp('1.2%'), + }, + sourceStatusFooter: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + sourceStatusMeta: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + }, + sourceStatusMetaText: { + color: 'rgba(255,255,255,0.5)', + fontSize: hp('1.4%'), + }, }); diff --git a/src/Screens/Novel/APIs/Details.js b/src/Screens/Novel/APIs/Details.js new file mode 100644 index 00000000..95919907 --- /dev/null +++ b/src/Screens/Novel/APIs/Details.js @@ -0,0 +1,441 @@ +/** + * Novel Details API + * Fetches and parses novel details and chapter list + * Supports multiple sources via hostKey parameter + */ + +import cheerio from 'cheerio'; +import { NovelHostName } from '../../../Utils/APIs'; +import { NovelDetailPageClasses, NovelChapterListPageClasses } from './constance'; +import { parseNovelDetails, parseChapterList, parseWTRLabDetails } from '../../../Redux/Actions/parsers/novelDetailParser'; +import { + recordSourceError, + recordSourceSuccess, +} from '../../../Utils/sourceStatus'; +import { getHostKeyFromLink } from './Reader'; + +/** + * Fetch novel details + * @param {string} novelLink - Novel link (e.g., '/book/shadow-slave') + * @param {string} hostKey - Source host key (default: 'novelfire') + * @returns {Promise} Novel details object + */ +export async function getNovelDetails(novelLink, hostKey = 'novelfire') { + // Auto-detect hostKey from link if not provided or default + const resolvedHostKey = (hostKey === 'novelfire' && novelLink) + ? getHostKeyFromLink(novelLink) + : hostKey; + + console.log('[getNovelDetails] novelLink:', novelLink); + console.log('[getNovelDetails] resolvedHostKey:', resolvedHostKey); + + const baseUrl = NovelHostName[resolvedHostKey] || NovelHostName.novelfire; + const config = NovelDetailPageClasses[resolvedHostKey] || NovelDetailPageClasses.novelfire; + + try { + // Ensure link is properly formatted + // WTR-Lab links may already include /en prefix or be full URLs + let link; + if (novelLink.startsWith('http')) { + link = novelLink; + } else if (resolvedHostKey === 'wtrlab') { + // WTR-Lab uses /en prefix for English pages + link = novelLink.startsWith('/en') ? `${baseUrl}${novelLink}` : `${baseUrl}/en${novelLink}`; + } else { + link = `${baseUrl}${novelLink}`; + } + + console.log('[getNovelDetails] Fetching from:', link); + console.log('[getNovelDetails] resolvedHostKey:', resolvedHostKey); + console.log('[getNovelDetails] baseUrl:', baseUrl); + + const response = await fetch(link, { + method: 'GET', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + redirect: 'follow', + }); + + console.log('[getNovelDetails] Response status:', response.status); + console.log('[getNovelDetails] Response OK:', response.ok); + console.log('[getNovelDetails] Response URL:', response.url); + + if (!response.ok) { + const statusCode = response.status; + recordSourceError(resolvedHostKey, statusCode); + throw new Error(`HTTP error! status: ${statusCode}`); + } + + const html = await response.text(); + const $ = cheerio.load(html); + + // Use WTR-Lab specific parser for wtrlab source + const details = resolvedHostKey === 'wtrlab' + ? parseWTRLabDetails($, config, link) + : parseNovelDetails($, config, link); + + console.log('[getNovelDetails] Parsed details:', { + title: details.title?.substring(0, 30), + imgSrc: details.imgSrc ? details.imgSrc.substring(0, 60) + '...' : null, + coverImage: details.coverImage ? details.coverImage.substring(0, 60) + '...' : null, + }); + + // Record successful request + recordSourceSuccess(resolvedHostKey); + + // Fetch first page of chapters to get pagination info + // Pass total chapter count for WTR-Lab to generate chapters programmatically + const chaptersData = await getChapterList(novelLink, 1, resolvedHostKey, details.chapters); + + console.log('[getNovelDetails] chaptersData:', { + chaptersCount: chaptersData?.chapters?.length, + pagination: chaptersData?.pagination, + }); + + const result = { + ...details, + // Normalize coverImage for backward compatibility + coverImage: details.imgSrc || details.coverImage, + link: novelLink, + chapterList: chaptersData.chapters, + chapterPagination: chaptersData.pagination, + }; + + console.log('[getNovelDetails] Final result coverImage:', result.coverImage ? result.coverImage.substring(0, 60) + '...' : null); + + return result; + } catch (error) { + console.error('Error fetching novel details:', error); + throw error; + } +} + +/** + * Fetch chapter list for a novel (single page) + * @param {string} novelLink - Novel link + * @param {number} page - Page number for paginated chapter lists + * @param {string} hostKey - Source host key (default: 'novelfire') + * @param {number} totalChapters - Total chapter count (optional, used for WTR-Lab) + * @returns {Promise} Object with chapters array and pagination info + */ +export async function getChapterList(novelLink, page = 1, hostKey = 'novelfire', totalChapters = null) { + console.log('[getChapterList] Called with:', { novelLink, page, hostKey, totalChapters }); + + // Auto-detect hostKey from link if not provided or default + const resolvedHostKey = (hostKey === 'novelfire' && novelLink) + ? getHostKeyFromLink(novelLink) + : hostKey; + + console.log('[getChapterList] resolvedHostKey:', resolvedHostKey); + + const baseUrl = NovelHostName[resolvedHostKey] || NovelHostName.novelfire; + // Use NovelChapterListPageClasses for chapter list parsing + const config = NovelChapterListPageClasses[resolvedHostKey] || NovelChapterListPageClasses.novelfire; + + try { + // WTR-Lab: Use API to fetch all chapters + console.log('[getChapterList] Checking if wtrlab:', resolvedHostKey, '=== "wtrlab":', resolvedHostKey === 'wtrlab'); + + if (resolvedHostKey === 'wtrlab') { + console.log('[getChapterList] WTR-Lab detected'); + + // Extract novel ID and slug from link + // Link format: https://wtr-lab.com/en/novel/{id}/{slug} or /en/novel/{id}/{slug} + const novelIdMatch = novelLink.match(/\/novel\/(\d+)\//); + const novelId = novelIdMatch ? novelIdMatch[1] : null; + const slugMatch = novelLink.match(/\/novel\/\d+\/([^\/\?]+)/); + const slug = slugMatch ? slugMatch[1] : ''; + + console.log('[getChapterList] Extracted novelId:', novelId, 'slug:', slug, 'from link:', novelLink); + + if (!novelId) { + console.error('[getChapterList] Could not extract novel ID from link:', novelLink); + // Fallback to HTML parsing + return await getChapterListFromHTML(novelLink, baseUrl, config, resolvedHostKey, page); + } + + // If we have the total chapter count, generate chapters programmatically + // This is the most reliable approach for WTR-Lab since the TOC is loaded dynamically + if (totalChapters && totalChapters > 0) { + console.log('[getChapterList] Generating chapters programmatically, total:', totalChapters); + + const chapters = []; + for (let i = 1; i <= totalChapters; i++) { + // WTR-Lab chapter URL format: /en/novel/{id}/{slug}/chapter-{number} + const chapterLink = `https://wtr-lab.com/en/novel/${novelId}/${slug}/chapter-${i}`; + chapters.push({ + number: i, + title: `Chapter ${i}`, + link: chapterLink, + id: i, + }); + } + + console.log('[getChapterList] Generated chapters:', chapters.length); + + recordSourceSuccess(resolvedHostKey); + + return { + chapters, + pagination: { + currentPage: 1, + totalPages: 1, + hasNext: false, + hasPrev: false, + totalChapters: chapters.length, + }, + }; + } + + // Fallback: Try API endpoint for chapters + const apiUrl = `https://wtr-lab.com/api/v2/series/${novelId}/chapters`; + console.log('[getChapterList] No totalChapters, trying API:', apiUrl); + + try { + const response = await fetch(apiUrl, { + method: 'GET', + headers: { + 'Accept': 'application/json, text/plain, */*', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': `https://wtr-lab.com/en/novel/${novelId}/`, + }, + }); + + console.log('[getChapterList] WTR-Lab API response status:', response.status); + + if (response.ok) { + const data = await response.json(); + console.log('[getChapterList] WTR-Lab API data:', { + chaptersCount: data?.chapters?.length || data?.length, + keys: Object.keys(data || {}), + sampleChapter: data?.chapters?.[0] || data?.[0], + }); + + // Parse the API response + // WTR-Lab API returns: { chapters: [{ id, number, title, ... }] } or just an array + const chaptersArray = data?.chapters || data || []; + + if (chaptersArray.length > 0) { + // Extract slug from novel link for building chapter URLs + const slugMatch = novelLink.match(/\/novel\/\d+\/([^\/\?]+)/); + const slug = slugMatch ? slugMatch[1] : ''; + + const chapters = chaptersArray.map((chapter, index) => { + // WTR-Lab chapter URL format: /en/novel/{id}/{slug}/chapter-{chapterId}-{chapterNumber} + const chapterId = chapter.id || chapter.chapter_id || chapter._id || index + 1; + const chapterNumber = chapter.number || chapter.chapter_number || chapter.num || (index + 1); + const chapterTitle = chapter.title || chapter.name || `Chapter ${chapterNumber}`; + + // Build the chapter link + const chapterLink = `https://wtr-lab.com/en/novel/${novelId}/${slug}/chapter-${chapterId}-${chapterNumber}`; + + return { + number: chapterNumber, + title: chapterTitle, + link: chapterLink, + id: chapterId, + }; + }); + + console.log('[getChapterList] Parsed chapters from API:', chapters.length); + + // Record successful request + recordSourceSuccess(resolvedHostKey); + + return { + chapters, + pagination: { + currentPage: 1, + totalPages: 1, + hasNext: false, + hasPrev: false, + totalChapters: chapters.length, + }, + }; + } + } + } catch (apiError) { + console.log('[getChapterList] API error, falling back to HTML:', apiError.message); + } + + // Fallback to HTML parsing if API fails or returns empty + console.log('[getChapterList] Falling back to HTML parsing'); + return await getChapterListFromHTML(novelLink, baseUrl, config, resolvedHostKey, page); + } + + // For other sources, use HTML parsing + return await getChapterListFromHTML(novelLink, baseUrl, config, resolvedHostKey, page); + } catch (error) { + console.error('Error fetching chapter list:', error); + throw error; + } +} + +/** + * Fetch chapter list from HTML (fallback for non-API sources) + */ +async function getChapterListFromHTML(novelLink, baseUrl, config, resolvedHostKey, page) { + try { + // Build the chapters URL based on source + let chaptersUrl; + if (resolvedHostKey === 'wtrlab') { + // WTR-Lab has chapters in the Table of Contents tab + const link = novelLink.startsWith('http') ? novelLink : + (novelLink.startsWith('/en') ? `${baseUrl}${novelLink}` : `${baseUrl}/en${novelLink}`); + chaptersUrl = `${link}?tab=toc`; + } else { + const link = novelLink.startsWith('http') ? novelLink : `${baseUrl}${novelLink}`; + chaptersUrl = `${link}/chapters?page=${page}`; + } + + console.log('[getChapterList] Fetching HTML from:', chaptersUrl); + + const response = await fetch(chaptersUrl, { + method: 'GET', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'Accept-Encoding': 'gzip, deflate, br', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + redirect: 'follow', + }); + + console.log('[getChapterList] Response status:', response.status); + + if (!response.ok) { + const statusCode = response.status; + recordSourceError(resolvedHostKey, statusCode); + throw new Error(`HTTP error! status: ${statusCode}`); + } + + const html = await response.text(); + const $ = cheerio.load(html); + + console.log('[getChapterListFromHTML] resolvedHostKey:', resolvedHostKey); + console.log('[getChapterListFromHTML] HTML length:', html.length); + + // For WTR-Lab, try to extract chapters from the HTML more thoroughly + if (resolvedHostKey === 'wtrlab') { + console.log('[getChapterListFromHTML] WTR-Lab specific parsing'); + const chapters = []; + const novelIdMatch = novelLink.match(/\/novel\/(\d+)\//); + const novelId = novelIdMatch ? novelIdMatch[1] : null; + const slugMatch = novelLink.match(/\/novel\/\d+\/([^\/\?]+)/); + const slug = slugMatch ? slugMatch[1] : ''; + + console.log('[getChapterListFromHTML] WTR-Lab novelId:', novelId, 'slug:', slug); + + // Find all chapter links with the pattern /chapter-{number} + // WTR-Lab format: /en/novel/{id}/{slug}/chapter-{number} + const chapterLinks = $('a[href*="/chapter-"]'); + console.log('[getChapterListFromHTML] Found chapter links:', chapterLinks.length); + + chapterLinks.each((index, el) => { + const $el = $(el); + const href = $el.attr('href'); + const title = $el.text().trim(); + + console.log('[getChapterListFromHTML] Chapter link:', href, 'title:', title?.substring(0, 30)); + + // Extract chapter number from URL + // Format 1: /chapter-{number} (e.g., /chapter-131) + // Format 2: /chapter-{id}-{number} (e.g., /chapter-12345-131) + let chapterNumber = null; + let chapterId = null; + + // Try format 2 first: chapter-{id}-{number} + const complexMatch = href.match(/chapter-(\d+)-(\d+)/); + if (complexMatch) { + chapterId = complexMatch[1]; + chapterNumber = parseInt(complexMatch[2], 10); + } else { + // Try format 1: chapter-{number} + const simpleMatch = href.match(/chapter-(\d+)/); + if (simpleMatch) { + chapterNumber = parseInt(simpleMatch[1], 10); + chapterId = chapterNumber; // Use number as ID + } + } + + if (chapterNumber) { + // Build full URL if needed + let chapterLink = href; + if (!chapterLink.startsWith('http')) { + chapterLink = chapterLink.startsWith('/') + ? `https://wtr-lab.com${chapterLink}` + : `https://wtr-lab.com/${chapterLink}`; + } + + chapters.push({ + number: chapterNumber, + title: title || `Chapter ${chapterNumber}`, + link: chapterLink, + id: chapterId, + }); + } + }); + + console.log('[getChapterListFromHTML] WTR-Lab found chapters before dedup:', chapters.length); + + // Remove duplicates based on chapter number + const uniqueChapters = []; + const seenNumbers = new Set(); + chapters.forEach((chapter) => { + if (!seenNumbers.has(chapter.number)) { + seenNumbers.add(chapter.number); + uniqueChapters.push(chapter); + } + }); + + // Sort by chapter number + uniqueChapters.sort((a, b) => a.number - b.number); + + console.log('[getChapterList] WTR-Lab HTML parsed chapters:', uniqueChapters.length); + + if (uniqueChapters.length > 0) { + recordSourceSuccess(resolvedHostKey); + return { + chapters: uniqueChapters, + pagination: { + currentPage: 1, + totalPages: 1, + hasNext: false, + hasPrev: false, + totalChapters: uniqueChapters.length, + }, + }; + } + } + + const result = parseChapterList($, config); + console.log('[getChapterList] result.chapters.length:', result?.chapters?.length); + + recordSourceSuccess(resolvedHostKey); + + return { + chapters: result?.chapters || [], + pagination: result?.pagination || { currentPage: page, totalPages: 1, hasNext: false, hasPrev: false }, + }; + } catch (error) { + console.error('Error fetching chapter list from HTML:', error); + throw error; + } +} + +export default { + getNovelDetails, + getChapterList, +}; + +// Re-export getHostKeyFromLink from Reader.js +export { getHostKeyFromLink }; diff --git a/src/Screens/Novel/APIs/Home.js b/src/Screens/Novel/APIs/Home.js new file mode 100644 index 00000000..4d39ebcc --- /dev/null +++ b/src/Screens/Novel/APIs/Home.js @@ -0,0 +1,768 @@ +/** + * Novel Home API + * Fetches and parses home page data for multiple sources + */ + +import cheerio from 'cheerio'; +import {NovelHostName} from '../../../Utils/APIs'; +import {NovelHomePageClasses} from './constance'; +import { + parseNovelHomeSections, + NOVELFIRE_HOME_CONFIG, +} from '../../../Redux/Actions/parsers/novelHomeParser'; +import { + recordSourceError, + recordSourceSuccess, +} from '../../../Utils/sourceStatus'; + +/** + * Get home page config for a source + * @param {string} hostKey - Source key (e.g., 'novelfire') + * @returns {Object|null} - Home page config + */ +const getHomeConfig = hostKey => { + if (hostKey === 'novelfire') { + return NOVELFIRE_HOME_CONFIG; + } + if (hostKey === 'wtrlab') { + return WTRLAB_HOME_CONFIG; + } + return null; +}; + +/** + * WTR-Lab home page configuration + */ +const WTRLAB_HOME_CONFIG = { + sourceKey: 'wtrlab', + baseUrl: 'https://wtr-lab.com', + sections: [ + { + name: 'New Novels', + url: '/en/novel-list', + cardSelector: '.novel-item, a[href*="/novel/"]', + titleSelector: '.novel-title, h3, h4', + linkSelector: 'a[href*="/novel/"]', + imageSelector: 'img', + imageAttr: 'src', + }, + { + name: 'Trending', + url: '/en/trending', + cardSelector: '.novel-item, a[href*="/novel/"]', + titleSelector: '.novel-title, h3, h4', + linkSelector: 'a[href*="/novel/"]', + imageSelector: 'img', + imageAttr: 'src', + }, + { + name: 'Ranking', + url: '/en/ranking/daily', + cardSelector: 'tr, .ranking-item', + titleSelector: 'a[href*="/novel/"]', + linkSelector: 'a[href*="/novel/"]', + imageSelector: 'img', + imageAttr: 'src', + }, + ], +}; + +/** + * Fetch novel home page data + * @param {string} hostKey - Source key (default: 'novelfire') + * @returns {Promise} Array of sections with novels + */ +export async function getNovelHome(hostKey = 'novelfire') { + const baseUrl = NovelHostName[hostKey]; + + if (!baseUrl) { + throw new Error(`Unknown host key: ${hostKey}`); + } + + // Use separate fetcher for WTR-Lab + if (hostKey === 'wtrlab') { + return getWTRLabHome(baseUrl); + } + + let statusCode = null; + try { + const response = await fetch(`${baseUrl}/home`, { + method: 'GET', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }); + + statusCode = response.status; + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + const $ = cheerio.load(html); + const config = getHomeConfig(hostKey); + + if (!config) { + throw new Error(`No config found for host: ${hostKey}`); + } + + const sections = parseNovelHomeSections($, config, {baseUrl}); + + // Record successful request + recordSourceSuccess(hostKey); + + return sections; + } catch (error) { + console.error('Error fetching novel home:', error); + + // Track source-specific errors + if (statusCode) { + recordSourceError(hostKey, statusCode); + } + + throw error; + } +} + +/** + * Fetch WTR-Lab home data from multiple endpoints + * WTR-Lab has separate pages for novel-list, trending, and ranking + * @param {string} baseUrl - Base URL for WTR-Lab + * @returns {Promise} Array of sections with novels + */ +async function getWTRLabHome(baseUrl) { + const sections = []; + const headers = { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }; + + console.log('[WTR-Lab] Fetching home data from', baseUrl); + + try { + // Fetch novel list page + try { + console.log('[WTR-Lab] Fetching novel-list...'); + const novelListResponse = await fetch(`${baseUrl}/en/novel-list`, { + method: 'GET', + headers, + }); + + if (novelListResponse.ok) { + const html = await novelListResponse.text(); + const $ = cheerio.load(html); + const novels = parseWTRLabNovelList($, baseUrl); + + if (novels.length > 0) { + sections.push({ + name: 'Novel List', + novels: novels.slice(0, 20), // Limit to 20 novels + }); + console.log(`[WTR-Lab] Novel List: ${novels.length} novels`); + } + } else { + console.log(`[WTR-Lab] Novel-list response: ${novelListResponse.status}`); + } + } catch (err) { + console.error('[WTR-Lab] Error fetching novel-list:', err.message); + } + + // Fetch trending page + try { + console.log('[WTR-Lab] Fetching trending...'); + const trendingResponse = await fetch(`${baseUrl}/en/trending`, { + method: 'GET', + headers, + }); + + if (trendingResponse.ok) { + const html = await trendingResponse.text(); + const $ = cheerio.load(html); + const novels = parseWTRLabNovelList($, baseUrl); + + if (novels.length > 0) { + sections.push({ + name: 'Trending', + novels: novels.slice(0, 20), + }); + console.log(`[WTR-Lab] Trending: ${novels.length} novels`); + } + } else { + console.log(`[WTR-Lab] Trending response: ${trendingResponse.status}`); + } + } catch (err) { + console.error('[WTR-Lab] Error fetching trending:', err.message); + } + + // Fetch ranking page + try { + console.log('[WTR-Lab] Fetching ranking...'); + const rankingResponse = await fetch(`${baseUrl}/en/ranking/daily`, { + method: 'GET', + headers, + }); + + if (rankingResponse.ok) { + const html = await rankingResponse.text(); + const $ = cheerio.load(html); + const novels = parseWTRLabRanking($, baseUrl); + + if (novels.length > 0) { + sections.push({ + name: 'Daily Ranking', + novels: novels.slice(0, 20), + }); + console.log(`[WTR-Lab] Daily Ranking: ${novels.length} novels`); + } + } else { + console.log(`[WTR-Lab] Ranking response: ${rankingResponse.status}`); + } + } catch (err) { + console.error('[WTR-Lab] Error fetching ranking:', err.message); + } + + // Record successful request if we got any data + if (sections.length > 0) { + recordSourceSuccess('wtrlab'); + console.log(`[WTR-Lab] Successfully fetched ${sections.length} sections`); + } else { + console.log('[WTR-Lab] No sections found'); + recordSourceError('wtrlab', 404); + } + + return sections; + } catch (error) { + console.error('[WTR-Lab] Error fetching home:', error); + recordSourceError('wtrlab', error.status || 500); + throw error; + } +} + +/** + * Parse WTR-Lab novel list page + * @param {Object} $ - Cheerio instance + * @param {string} baseUrl - Base URL + * @returns {Array} Array of novels + */ +function parseWTRLabNovelList($, baseUrl) { + const novels = []; + const seenLinks = new Set(); + const seenNovelIds = new Set(); + + // First, find all images with /api/v2/img src to build a map + const imageMap = {}; + $('img[src*="/api/v2/img"]').each((index, img) => { + const $img = $(img); + const src = $img.attr('src'); + // Find the closest novel link + const $closestLink = $img.closest('a[href*="/en/novel/"]'); + if ($closestLink.length > 0) { + const href = $closestLink.attr('href'); + const novelIdMatch = href.match(/\/novel\/(\d+)\//); + if (novelIdMatch) { + imageMap[novelIdMatch[1]] = src; + } + } + }); + + // Also check for data-src attributes (lazy loading) + $('img[data-src*="/api/v2/img"]').each((index, img) => { + const $img = $(img); + const src = $img.attr('data-src'); + // Find the closest novel link + const $closestLink = $img.closest('a[href*="/en/novel/"]'); + if ($closestLink.length > 0) { + const href = $closestLink.attr('href'); + const novelIdMatch = href.match(/\/novel\/(\d+)\//); + if (novelIdMatch) { + if (!imageMap[novelIdMatch[1]]) { + imageMap[novelIdMatch[1]] = src; + } + } + } + }); + + console.log(`[WTR-Lab] Found ${Object.keys(imageMap).length} images in imageMap`); + + // WTR-Lab uses links with href pattern /en/novel/{id}/{slug} + $('a[href*="/en/novel/"]').each((index, element) => { + const $el = $(element); + + // Get link + let link = $el.attr('href'); + + if (!link) { + return; // Skip if no link + } + + // Skip non-novel links (like /continue, /details, etc.) + if (link.includes('/continue') || link.includes('/details') || link.includes('/start')) { + return; + } + + // Normalize link + if (link && !link.startsWith('http')) { + link = link.startsWith('/') ? `${baseUrl}${link}` : `${baseUrl}/${link}`; + } + + // Skip duplicates + if (seenLinks.has(link)) { + return; + } + seenLinks.add(link); + + // Get title - it's the link text + let title = $el.text().trim(); + + // Clean up title - remove extra whitespace and newlines + title = title.split('\n')[0].trim(); + + // Remove ranking prefixes like "#1", "#2", etc. + title = title.replace(/^#\d+\s*/i, '').trim(); + + // Skip if title is too short or empty + if (!title || title.length < 2) { + return; + } + + // Extract novel ID from link for cover image + // Link format: /en/novel/{id}/{slug} or https://wtr-lab.com/en/novel/{id}/{slug} + const novelIdMatch = link.match(/\/novel\/(\d+)\//); + const novelId = novelIdMatch ? novelIdMatch[1] : null; + + // Skip if we've already seen this novel ID + if (novelId && seenNovelIds.has(novelId)) { + return; + } + if (novelId) { + seenNovelIds.add(novelId); + } + + // Get the parent element to find additional info + const $parent = $el.parent(); + const parentText = $parent.text(); + + // Get status (Completed/Ongoing) + let status = 'Ongoing'; + if (parentText.includes('Completed')) { + status = 'Completed'; + } + + // Get chapters count + const chaptersMatch = parentText.match(/(\d+)\s*Chapters?/i); + const chapters = chaptersMatch ? parseInt(chaptersMatch[1], 10) : null; + + // Get image from the imageMap first, then fall back to DOM search + let coverImage = null; + + if (novelId && imageMap[novelId]) { + coverImage = imageMap[novelId]; + } else { + // Fallback: look for img in nearby elements + coverImage = + $el.find('img').attr('src') || + $el.find('img').attr('data-src') || + $parent.find('img').attr('src') || + $parent.find('img').attr('data-src'); + } + + // Handle WTR-Lab image URLs + if (coverImage) { + // Decode HTML entities (like & -> &) + coverImage = coverImage.replace(/&/g, '&'); + + // If image URL is relative (starts with /api), prepend base URL + if (coverImage.startsWith('/api')) { + coverImage = `${baseUrl}${coverImage}`; + } else if (!coverImage.startsWith('http')) { + coverImage = coverImage.startsWith('//') ? `https:${coverImage}` : + coverImage.startsWith('/') ? `${baseUrl}${coverImage}` : + `${baseUrl}/${coverImage}`; + } + } + + // Debug log for first few novels + if (novels.length < 3) { + console.log(`[WTR-Lab] Novel ${novels.length + 1}:`, { + title: title.substring(0, 30), + link, + coverImage: coverImage ? coverImage.substring(0, 80) + '...' : null, + novelId, + status, + chapters, + }); + } + + novels.push({ + title, + link, + coverImage, + status, + chapters, + novelId, + }); + }); + + console.log(`[WTR-Lab] Parsed ${novels.length} novels from list page`); + return novels; +} + +/** + * Parse WTR-Lab ranking page (table format) + * @param {Object} $ - Cheerio instance + * @param {string} baseUrl - Base URL + * @returns {Array} Array of novels + */ +function parseWTRLabRanking($, baseUrl) { + const novels = []; + const seenLinks = new Set(); + const seenNovelIds = new Set(); + + // First, find all images with /api/v2/img src to build a map + const imageMap = {}; + $('img[src*="/api/v2/img"]').each((index, img) => { + const $img = $(img); + const src = $img.attr('src'); + // Find the closest novel link + const $closestLink = $img.closest('a[href*="/en/novel/"]'); + if ($closestLink.length > 0) { + const href = $closestLink.attr('href'); + const novelIdMatch = href.match(/\/novel\/(\d+)\//); + if (novelIdMatch) { + imageMap[novelIdMatch[1]] = src; + } + } + }); + + // Also check for data-src attributes (lazy loading) + $('img[data-src*="/api/v2/img"]').each((index, img) => { + const $img = $(img); + const src = $img.attr('data-src'); + // Find the closest novel link + const $closestLink = $img.closest('a[href*="/en/novel/"]'); + if ($closestLink.length > 0) { + const href = $closestLink.attr('href'); + const novelIdMatch = href.match(/\/novel\/(\d+)\//); + if (novelIdMatch) { + if (!imageMap[novelIdMatch[1]]) { + imageMap[novelIdMatch[1]] = src; + } + } + } + }); + + console.log(`[WTR-Lab Ranking] Found ${Object.keys(imageMap).length} images in imageMap`); + + // WTR-Lab ranking uses links with href pattern /en/novel/{id}/{slug} + // Each novel has a rank number shown as #1, #2, etc. + $('a[href*="/en/novel/"]').each((index, element) => { + const $el = $(element); + + // Get link + let link = $el.attr('href'); + + if (!link) { + return; + } + + // Skip non-novel links (like /continue, /details, etc.) + if (link.includes('/continue') || link.includes('/details') || link.includes('/start')) { + return; + } + + // Normalize link + if (link && !link.startsWith('http')) { + link = link.startsWith('/') ? `${baseUrl}${link}` : `${baseUrl}/${link}`; + } + + // Skip duplicates + if (seenLinks.has(link)) { + return; + } + seenLinks.add(link); + + // Get title - it's the link text + let title = $el.text().trim(); + + // Clean up title + title = title.split('\n')[0].trim(); + + // Remove ranking prefixes like "#1", "#2", etc. + title = title.replace(/^#\d+\s*/i, '').trim(); + + // Skip if title is too short or empty + if (!title || title.length < 2) { + return; + } + + // Extract novel ID from link for cover image + // Link format: /en/novel/{id}/{slug} or https://wtr-lab.com/en/novel/{id}/{slug} + const novelIdMatch = link.match(/\/novel\/(\d+)\//); + const novelId = novelIdMatch ? novelIdMatch[1] : null; + + // Skip if we've already seen this novel ID + if (novelId && seenNovelIds.has(novelId)) { + return; + } + if (novelId) { + seenNovelIds.add(novelId); + } + + // Get the parent element to find additional info + const $parent = $el.parent(); + const parentText = $parent.text(); + + // Get status (Completed/Ongoing/Dropped) + let status = 'Ongoing'; + if (parentText.includes('Completed')) { + status = 'Completed'; + } else if (parentText.includes('Dropped')) { + status = 'Dropped'; + } + + // Get chapters count + const chaptersMatch = parentText.match(/(\d+)\s*Chapters?/i); + const chapters = chaptersMatch ? parseInt(chaptersMatch[1], 10) : null; + + // Get daily views for ranking + const viewsMatch = parentText.match(/(\d+(?:,\d+)*)\s*(?:Daily\s*)?views?/i); + const views = viewsMatch ? parseInt(viewsMatch[1].replace(/,/g, ''), 10) : null; + + // Get image from the imageMap first, then fall back to DOM search + let coverImage = null; + + if (novelId && imageMap[novelId]) { + coverImage = imageMap[novelId]; + } else { + // Fallback: look for img in nearby elements + coverImage = + $el.find('img').attr('src') || + $el.find('img').attr('data-src') || + $parent.find('img').attr('src') || + $parent.find('img').attr('data-src'); + } + + // Handle WTR-Lab image URLs + if (coverImage) { + // Decode HTML entities (like & -> &) + coverImage = coverImage.replace(/&/g, '&'); + + // If image URL is relative (starts with /api), prepend base URL + if (coverImage.startsWith('/api')) { + coverImage = `${baseUrl}${coverImage}`; + } else if (!coverImage.startsWith('http')) { + coverImage = coverImage.startsWith('//') ? `https:${coverImage}` : + coverImage.startsWith('/') ? `${baseUrl}${coverImage}` : + `${baseUrl}/${coverImage}`; + } + } + + // Debug log for first few novels + if (novels.length < 3) { + console.log(`[WTR-Lab Ranking] Novel ${novels.length + 1}:`, { + title: title.substring(0, 30), + link, + coverImage: coverImage ? coverImage.substring(0, 80) + '...' : null, + novelId, + status, + chapters, + }); + } + + novels.push({ + title, + link, + coverImage, + status, + chapters, + views, + novelId, + }); + }); + + console.log(`[WTR-Lab] Parsed ${novels.length} novels from ranking page`); + return novels; +} + +/** + * Fetch novels by genre + * @param {string} genre - Genre name (e.g., 'Action', 'Fantasy') + * @param {number} page - Page number + * @param {string} hostKey - Source key (default: 'novelfire') + * @returns {Promise} Array of novels + */ +export async function getNovelsByGenre(genre, page = 1, hostKey = 'novelfire') { + const baseUrl = NovelHostName[hostKey]; + + if (!baseUrl) { + throw new Error(`Unknown host key: ${hostKey}`); + } + + let statusCode = null; + try { + const genreSlug = genre.toLowerCase().replace(/\s+/g, '-'); + const url = `${baseUrl}/genre-${genreSlug}/sort-popular/status-all/all-novel?page=${page}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }); + + statusCode = response.status; + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + const $ = cheerio.load(html); + const config = getHomeConfig(hostKey); + + if (!config) { + throw new Error(`No config found for host: ${hostKey}`); + } + + const sections = parseNovelHomeSections($, config, {baseUrl}); + + // Record successful request + recordSourceSuccess(hostKey); + + return sections?.[0]?.novels || []; + } catch (error) { + console.error('Error fetching novels by genre:', error); + + // Track source-specific errors + if (statusCode) { + recordSourceError(hostKey, statusCode); + } + + throw error; + } +} + +/** + * Fetch latest novels + * @param {number} page - Page number + * @param {string} hostKey - Source key (default: 'novelfire') + * @returns {Promise} Array of novels + */ +export async function getLatestNovels(page = 1, hostKey = 'novelfire') { + const baseUrl = NovelHostName[hostKey]; + + if (!baseUrl) { + throw new Error(`Unknown host key: ${hostKey}`); + } + + let statusCode = null; + try { + const url = `${baseUrl}/genre-all/sort-new/status-all/all-novel?page=${page}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }); + + statusCode = response.status; + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + const $ = cheerio.load(html); + const config = getHomeConfig(hostKey); + + if (!config) { + throw new Error(`No config found for host: ${hostKey}`); + } + + const sections = parseNovelHomeSections($, config, {baseUrl}); + + // Record successful request + recordSourceSuccess(hostKey); + + return sections?.[0]?.novels || []; + } catch (error) { + console.error('Error fetching latest novels:', error); + + // Track source-specific errors + if (statusCode) { + recordSourceError(hostKey, statusCode); + } + + throw error; + } +} + +/** + * Fetch completed novels + * @param {number} page - Page number + * @param {string} hostKey - Source key (default: 'novelfire') + * @returns {Promise} Array of novels + */ +export async function getCompletedNovels(page = 1, hostKey = 'novelfire') { + const baseUrl = NovelHostName[hostKey]; + + if (!baseUrl) { + throw new Error(`Unknown host key: ${hostKey}`); + } + + let statusCode = null; + try { + const url = `${baseUrl}/genre-all/sort-popular/status-completed/all-novel?page=${page}`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }); + + statusCode = response.status; + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + const $ = cheerio.load(html); + const config = getHomeConfig(hostKey); + + if (!config) { + throw new Error(`No config found for host: ${hostKey}`); + } + + const sections = parseNovelHomeSections($, config, {baseUrl}); + + // Record successful request + recordSourceSuccess(hostKey); + + return sections?.[0]?.novels || []; + } catch (error) { + console.error('Error fetching completed novels:', error); + + // Track source-specific errors + if (statusCode) { + recordSourceError(hostKey, statusCode); + } + + throw error; + } +} + +export default { + getNovelHome, + getNovelsByGenre, + getLatestNovels, + getCompletedNovels, +}; \ No newline at end of file diff --git a/src/Screens/Novel/APIs/Reader.js b/src/Screens/Novel/APIs/Reader.js new file mode 100644 index 00000000..3bc445da --- /dev/null +++ b/src/Screens/Novel/APIs/Reader.js @@ -0,0 +1,374 @@ +/** + * Novel Reader API + * Fetches and parses chapter content from multiple sources + */ + +import cheerio from 'cheerio'; +import { NovelHostName } from '../../../Utils/APIs'; +import { NovelChapterPageClasses } from './constance'; +import { parseNovelChapter, parseWTRLabChapter } from '../../../Redux/Actions/parsers/novelChapterParser'; +import { + recordSourceError, + recordSourceSuccess, +} from '../../../Utils/sourceStatus'; + +/** + * Detect source hostKey from a URL + * @param {string} link - Full URL or path + * @returns {string} hostKey (e.g., 'novelfire') + */ +export function getHostKeyFromLink(link) { + if (!link) return 'novelfire'; + + console.log('[getHostKeyFromLink] Checking link:', link); + + // Check if link contains a known host + for (const [key, baseUrl] of Object.entries(NovelHostName)) { + const hostWithoutProtocol = baseUrl.replace('https://', '').replace('http://', ''); + console.log('[getHostKeyFromLink] Checking host:', key, hostWithoutProtocol, 'includes:', link.includes(hostWithoutProtocol)); + if (link.includes(hostWithoutProtocol)) { + console.log('[getHostKeyFromLink] Matched host:', key); + return key; + } + } + + console.log('[getHostKeyFromLink] No match found, defaulting to novelfire'); + // Default to novelfire + return 'novelfire'; +} + +const WTR_LAB_JUNK_PATTERNS = [ + '!function(', + 'window.matchMedia(', + 'document.body.classList', + 'document.documentElement.setAttribute', + 'localStorage.getItem("config")', + 'data-bs-theme', + ':root {', + '--bprogress-color', +]; + +const hasEncryptedWTRLabBody = readerData => + typeof readerData?.data?.data?.body === 'string' && + readerData.data.data.body.startsWith('arr:'); + +const isLikelyJunkChapterText = text => { + if (typeof text !== 'string') { + return true; + } + + const normalized = text.trim(); + if (!normalized) { + return true; + } + + const sample = normalized.slice(0, 1500); + return WTR_LAB_JUNK_PATTERNS.some(pattern => sample.includes(pattern)); +}; + +/** + * Fetch chapter content + * @param {string} chapterLink - Chapter link (e.g., '/book/shadow-slave/chapter-1') + * @param {string} hostKey - Source key (default: 'novelfire') + * @param {string} readingMode - Reading mode for WTR-Lab ('web' | 'webplus' | 'ai') + * @returns {Promise} Chapter content object + */ +export async function getNovelChapter(chapterLink, hostKey = 'novelfire', readingMode = 'web') { + // Detect hostKey from link if it's a full URL + const detectedHostKey = chapterLink.startsWith('http') + ? getHostKeyFromLink(chapterLink) + : hostKey; + + const baseUrl = NovelHostName[detectedHostKey]; + + try { + // Ensure link is properly formatted + let link = chapterLink.startsWith('http') ? chapterLink : `${baseUrl}${chapterLink}`; + + // WTR-Lab: Add /en prefix if needed and reading mode parameter + if (detectedHostKey === 'wtrlab') { + // Add /en prefix if not present + if (!link.includes('/en/') && !link.includes('wtr-lab.com/en')) { + link = link.replace('wtr-lab.com', 'wtr-lab.com/en'); + } + + // WTR-Lab has different reading modes: + // - 'web': Server-side rendered HTML - but content still loaded via JS + // - 'webplus': Enhanced web mode - might have more SSR content + // - 'ai': AI-translated mode (default on website) + // WTR-Lab: Use the reader API to get chapter content + // The content is loaded via a POST API, not in the initial HTML + console.log('[getNovelChapter] WTR-Lab: Using reader API for chapter content'); + + const effectiveReadingMode = readingMode || 'web'; + const buildPageUrl = mode => { + if (!mode || mode === 'ai') { + return link; + } + + const separator = link.includes('?') ? '&' : '?'; + return `${link}${separator}service=${mode}`; + }; + + const toReadableChapter = (chapter, modeUsed) => { + const hasParagraphs = + Array.isArray(chapter?.paragraphs) && chapter.paragraphs.length > 0; + const hasText = typeof chapter?.text === 'string' && chapter.text.trim().length > 0; + + if (!hasParagraphs && !hasText) { + return null; + } + + if (isLikelyJunkChapterText(chapter?.text || '')) { + console.log( + '[getNovelChapter] Rejected WTR-Lab chapter candidate because it looks like page HTML/CSS/JS', + ); + return null; + } + + return { + ...chapter, + link: chapterLink, + hostKey: detectedHostKey, + requestedReadingMode: effectiveReadingMode, + activeReadingMode: modeUsed, + }; + }; + + const pageUrl = buildPageUrl(effectiveReadingMode); + + console.log('[getNovelChapter] WTR-Lab using reading mode:', effectiveReadingMode); + + // First, fetch the page to get chapter metadata from __NEXT_DATA__ + const pageResponse = await fetch(pageUrl, { + method: 'GET', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15', + }, + }); + + if (!pageResponse.ok) { + throw new Error(`HTTP error! status: ${pageResponse.status}`); + } + + const html = await pageResponse.text(); + const $ = cheerio.load(html); + + // Extract chapter metadata from __NEXT_DATA__ + const nextDataScript = $('script#__NEXT_DATA__').html(); + let nextData = null; + if (nextDataScript) { + try { + nextData = JSON.parse(nextDataScript); + } catch (e) { + console.log('[getNovelChapter] Failed to parse WTR-Lab __NEXT_DATA__:', e.message); + } + } + + const serie = nextData?.props?.pageProps?.serie; + const chapterData = serie?.chapter; + + if (!chapterData?.id || !chapterData?.raw_id || !chapterData?.order) { + throw new Error('Could not find chapter metadata in WTR-Lab page data'); + } + + console.log('[getNovelChapter] WTR-Lab chapter metadata:', { + chapterId: chapterData.id, + rawId: chapterData.raw_id, + chapterNo: chapterData.order, + title: chapterData.title, + }); + + const readerApiUrl = 'https://wtr-lab.com/api/reader/get'; + const attemptedModes = [effectiveReadingMode]; + if (effectiveReadingMode !== 'ai') { + attemptedModes.push('ai'); + } + + let lastReaderError = null; + + for (const mode of attemptedModes) { + const modePageUrl = buildPageUrl(mode); + const readerPayload = { + translate: mode, + language: 'en', + raw_id: chapterData.raw_id, + chapter_no: chapterData.order, + retry: false, + force_retry: false, + chapter_id: chapterData.id, + }; + + console.log('[getNovelChapter] WTR-Lab calling reader API with payload:', readerPayload); + + try { + const readerResponse = await fetch(readerApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': '*/*', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.3.1 Safari/605.1.15', + 'Origin': 'https://wtr-lab.com', + 'Referer': modePageUrl, + }, + body: JSON.stringify(readerPayload), + }); + + if (!readerResponse.ok) { + throw new Error(`Reader API error! status: ${readerResponse.status}`); + } + + const readerData = await readerResponse.json(); + console.log('[getNovelChapter] WTR-Lab reader API response keys:', Object.keys(readerData)); + console.log('[getNovelChapter] WTR-Lab reader API response:', JSON.stringify(readerData).substring(0, 1000)); + + if (hasEncryptedWTRLabBody(readerData)) { + console.log( + `[getNovelChapter] WTR-Lab ${mode} mode returned encrypted arr body, trying fallback mode`, + ); + continue; + } + + const chapter = toReadableChapter( + parseWTRLabChapter($, {}, readerData, chapterData), + mode, + ); + + console.log('[getNovelChapter] Parsed chapter:', { + mode, + title: chapter?.title, + textLength: chapter?.text?.length, + paragraphsCount: chapter?.paragraphs?.length, + }); + + if (chapter) { + recordSourceSuccess(detectedHostKey); + return chapter; + } + } catch (readerError) { + lastReaderError = readerError; + console.log( + `[getNovelChapter] WTR-Lab ${mode} mode failed:`, + readerError.message, + ); + } + } + + const htmlChapter = toReadableChapter( + parseWTRLabChapter($, {}, null, chapterData), + effectiveReadingMode, + ); + + if (htmlChapter) { + recordSourceSuccess(detectedHostKey); + return htmlChapter; + } + + throw lastReaderError || new Error('Could not extract readable chapter content from WTR-Lab'); + } + + // For non-WTR-Lab sources, use the standard GET approach + const response = await fetch(link, { + method: 'GET', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,text/plain;q=0.8,*/*;q=0.7', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + const $ = cheerio.load(html); + + // Get source-specific config + const config = NovelChapterPageClasses[detectedHostKey]; + + // Parse chapter using generic parser with config + const chapter = parseNovelChapter($, { + contentSelector: config?.contentContainer || '#content, .chapter-content, article', + titleSelector: config?.title || 'h1, h2, h3, h4, .chapter-title', + prevSelector: config?.prevChapter || '.prev, .prevchap, a[rel="prev"]', + nextSelector: config?.nextChapter || '.next, .nextchap, a[rel="next"]', + paragraphSelector: config?.paragraphs || 'p', + }); + + console.log('[getNovelChapter] Parsed chapter:', { + title: chapter?.title, + textLength: chapter?.text?.length, + paragraphsCount: chapter?.paragraphs?.length, + prevChapter: chapter?.prevChapter, + nextChapter: chapter?.nextChapter, + }); + + // Record successful request + recordSourceSuccess(detectedHostKey); + + return { + ...chapter, + link: chapterLink, + hostKey: detectedHostKey, + }; + } catch (error) { + console.error('Error fetching chapter:', error); + + // Record error for source tracking + const statusCode = error?.response?.status || error?.status; + if (statusCode) { + recordSourceError(detectedHostKey, statusCode); + } + + throw error; + } +} + +/** + * Fetch multiple chapters for offline reading + * @param {string} novelLink - Novel link + * @param {number} startChapter - Starting chapter number + * @param {number} endChapter - Ending chapter number + * @param {string} hostKey - Source key (default: 'novelfire') + * @param {string} readingMode - Reading mode for WTR-Lab ('web' | 'webplus' | 'ai') + * @returns {Promise} Array of chapter contents + */ +export async function getMultipleChapters(novelLink, startChapter, endChapter, hostKey = 'novelfire', readingMode = 'web') { + const chapters = []; + const baseUrl = NovelHostName[hostKey]; + + for (let i = startChapter; i <= endChapter; i++) { + try { + let chapterLink; + if (hostKey === 'wtrlab') { + // WTR-Lab chapter URL format: /en/novel/{id}/{slug}/chapter-{num} + const link = novelLink.startsWith('http') ? novelLink : + (novelLink.startsWith('/en') ? `${baseUrl}${novelLink}` : `${baseUrl}/en${novelLink}`); + chapterLink = `${link}/chapter-${i}`; + } else { + chapterLink = novelLink.startsWith('http') + ? `${novelLink}/chapter-${i}` + : `${baseUrl}${novelLink}/chapter-${i}`; + } + const chapter = await getNovelChapter(chapterLink, hostKey, readingMode); + chapters.push(chapter); + + // Add small delay to avoid rate limiting + await new Promise(resolve => setTimeout(resolve, 500)); + } catch (error) { + console.error(`Error fetching chapter ${i}:`, error); + } + } + + return chapters; +} + +export default { + getNovelChapter, + getMultipleChapters, + getHostKeyFromLink, +}; diff --git a/src/Screens/Novel/APIs/Search.js b/src/Screens/Novel/APIs/Search.js new file mode 100644 index 00000000..38e75579 --- /dev/null +++ b/src/Screens/Novel/APIs/Search.js @@ -0,0 +1,144 @@ +/** + * Novel Search API + * Fetches and parses search results from multiple sources + */ + +import { parseSearchResults } from './novelParser'; +import { NovelHostName } from '../../../Utils/APIs'; +import { NovelSearchPageClasses } from './constance'; +import { + recordSourceError, + recordSourceSuccess, +} from '../../../Utils/sourceStatus'; + +/** + * Search novels + * @param {string} query - Search query + * @param {number} page - Page number + * @param {string} hostKey - Source key (default: 'novelfire') + * @returns {Promise} Array of novels + */ +export async function searchNovels(query, page = 1, hostKey = 'novelfire') { + const baseUrl = NovelHostName[hostKey]; + + if (!baseUrl) { + console.error(`Unknown hostKey: ${hostKey}`); + return null; + } + + try { + const encodedQuery = encodeURIComponent(query); + + // WTR-Lab uses different search URL pattern + let url; + if (hostKey === 'wtrlab') { + url = `${baseUrl}/en/novel-list?search=${encodedQuery}&page=${page}`; + } else { + url = `${baseUrl}/search?keyword=${encodedQuery}&type=title&page=${page}`; + } + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }); + + if (!response.ok) { + recordSourceError(hostKey, response.status); + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + const results = parseSearchResults(html); + + // Record successful request + recordSourceSuccess(hostKey); + + return results; + } catch (error) { + console.error('Error searching novels:', error); + + // Track error if not already tracked (network errors, etc.) + const statusCode = error?.response?.status || error?.status; + if (statusCode) { + recordSourceError(hostKey, statusCode); + } + + throw error; + } +} + +/** + * Search novels by author + * @param {string} authorName - Author name + * @param {string} hostKey - Source key (default: 'novelfire') + * @returns {Promise} Array of novels + */ +export async function searchByAuthor(authorName, hostKey = 'novelfire') { + const baseUrl = NovelHostName[hostKey]; + + if (!baseUrl) { + console.error(`Unknown hostKey: ${hostKey}`); + return null; + } + + try { + const encodedAuthor = encodeURIComponent(authorName); + const url = `${baseUrl}/search?keyword=${encodedAuthor}&type=author`; + + const response = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.9', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + }, + }); + + if (!response.ok) { + recordSourceError(hostKey, response.status); + throw new Error(`HTTP error! status: ${response.status}`); + } + + const html = await response.text(); + const novels = parseSearchResults(html); + + // Filter results to only include novels by the specified author + const filteredResults = novels?.filter(novel => + novel.author?.toLowerCase().includes(authorName.toLowerCase()) + ) || []; + + // Record successful request + recordSourceSuccess(hostKey); + + return filteredResults; + } catch (error) { + console.error('Error searching by author:', error); + + // Track error if not already tracked (network errors, etc.) + const statusCode = error?.response?.status || error?.status; + if (statusCode) { + recordSourceError(hostKey, statusCode); + } + + throw error; + } +} + +/** + * Get search page config for a source + * @param {string} hostKey - Source key + * @returns {Object|null} Search page configuration + */ +export function getSearchConfig(hostKey) { + return NovelSearchPageClasses[hostKey] || null; +} + +export default { + searchNovels, + searchByAuthor, + getSearchConfig, +}; \ No newline at end of file diff --git a/src/Screens/Novel/APIs/constance.js b/src/Screens/Novel/APIs/constance.js new file mode 100644 index 00000000..bfddf06d --- /dev/null +++ b/src/Screens/Novel/APIs/constance.js @@ -0,0 +1,570 @@ +/** + * NovelFire.net CSS Selectors for HTML Parsing + * Used with cheerio for parsing novel pages + */ + +export const NovelHomePageClasses = { + novelfire: { + // Home page sections + recommends: { + sectionTitle: 'h3:contains("Recommends")', + sectionBody: '.section-body', + cardClass: 'li.novel-item', + cardLinkClass: 'a[href*="/book/"]', + cardTitleClass: 'a[href*="/book/"]', + titleAttr: 'title', + imageClass: 'img', + imageAttr: 'data-src', // Lazy loading + imageFallbackAttr: 'src', + ratingClass: '.badge._br', + ratingIcon: 'i.icon-star', + chaptersClass: 'span:contains("Chapters")', + authorClass: 'span.author, a[href*="/author/"]', + }, + 'most-read': { + sectionTitle: 'h3 span:contains("Most Read")', + sectionBody: '.section-body', + cardClass: 'li.novel-item', + cardLinkClass: 'a[href*="/book/"]', + cardTitleClass: 'a[href*="/book/"]', + titleAttr: 'title', + imageClass: 'img', + imageAttr: 'data-src', + imageFallbackAttr: 'src', + ratingClass: '.badge._br', + ratingIcon: 'i.icon-star', + chaptersClass: 'span:contains("Chapters")', + }, + 'new-trend': { + sectionTitle: 'h3 span:contains("New Trend")', + sectionBody: '.section-body', + cardClass: 'li.novel-item', + cardLinkClass: 'a[href*="/book/"]', + cardTitleClass: 'a[href*="/book/"]', + titleAttr: 'title', + imageClass: 'img', + imageAttr: 'data-src', + imageFallbackAttr: 'src', + ratingClass: '.badge._br', + ratingIcon: 'i.icon-star', + chaptersClass: 'span:contains("Chapters")', + }, + 'user-rated': { + sectionTitle: 'h3 span:contains("User Rated")', + sectionBody: '.section-body', + cardClass: 'li.novel-item', + cardLinkClass: 'a[href*="/book/"]', + cardTitleClass: 'a[href*="/book/"]', + titleAttr: 'title', + imageClass: 'img', + imageAttr: 'data-src', + imageFallbackAttr: 'src', + ratingClass: '.badge._br', + ratingIcon: 'i.icon-star', + chaptersClass: 'span:contains("Chapters")', + }, + 'latest-novels': { + sectionTitle: 'h3:contains("Latest Novels")', + sectionBody: '.section-body', + cardClass: 'li.novel-item', + cardLinkClass: 'a[href*="/book/"]', + cardTitleClass: 'a[href*="/book/"]', + titleAttr: 'title', + imageClass: 'img', + imageAttr: 'data-src', + imageFallbackAttr: 'src', + ratingClass: '.badge._br', + ratingIcon: 'i.icon-star', + chaptersClass: 'span:contains("Chapters")', + }, + 'completed-stories': { + sectionTitle: 'h3:contains("Completed Stories")', + sectionBody: '.section-body', + cardClass: 'li.novel-item', + cardLinkClass: 'a[href*="/book/"]', + cardTitleClass: 'a[href*="/book/"]', + titleAttr: 'title', + imageClass: 'img', + imageAttr: 'data-src', + imageFallbackAttr: 'src', + ratingClass: '.badge._br', + ratingIcon: 'i.icon-star', + chaptersClass: 'span:contains("Chapters")', + }, + // Generic fallback for any novel list + 'all-novels': { + cardClass: 'li.novel-item', + cardLinkClass: 'a[href*="/book/"]', + cardTitleClass: 'a[href*="/book/"]', + titleAttr: 'title', + imageClass: 'img', + imageAttr: 'data-src', + imageFallbackAttr: 'src', + ratingClass: '.badge._br', + ratingIcon: 'i.icon-star', + chaptersClass: 'span:contains("Chapters")', + authorClass: 'span.author, a[href*="/author/"]', + }, + }, + wtrlab: { + // WTR-Lab home page sections + newNovels: { + sectionTitle: 'h2, .section-title', + sectionBody: '.novel-list, .content', + cardClass: '.novel-item, a[href*="/novel/"]', + cardLinkClass: 'a[href*="/novel/"]', + cardTitleClass: '.novel-title, h3, h4', + titleAttr: 'title', + imageClass: 'img', + imageAttr: 'src', + imageFallbackAttr: 'data-src', + ratingClass: '.rating, span:contains("★")', + chaptersClass: 'span:contains("Chapters"), .chapters', + statusClass: '.status', + genresClass: '.genres a, .tags a', + }, + ranking: { + sectionTitle: 'h2:contains("Ranking"), .section-title:contains("Ranking")', + sectionBody: 'table, .ranking-list', + cardClass: 'tr, .ranking-item', + cardLinkClass: 'a[href*="/novel/"]', + cardTitleClass: 'a[href*="/novel/"]', + titleAttr: 'title', + imageClass: 'img', + imageAttr: 'src', + imageFallbackAttr: 'data-src', + ratingClass: '.rating, span:contains("★")', + chapters: '.chapters', + views: '.views, span:contains("view")', + }, + trending: { + sectionTitle: 'h2:contains("Trending"), .section-title:contains("Trending")', + sectionBody: '.novel-list, .trending-list', + cardClass: '.novel-item, a[href*="/novel/"]', + cardLinkClass: 'a[href*="/novel/"]', + cardTitleClass: '.novel-title, h3, h4', + titleAttr: 'title', + imageClass: 'img', + imageAttr: 'src', + imageFallbackAttr: 'data-src', + ratingClass: '.rating, span:contains("★")', + chaptersClass: 'span:contains("Chapters"), .chapters', + genresClass: '.genres a', + }, + // Generic fallback for novel list pages + 'all-novels': { + cardClass: '.novel-item, a[href*="/novel/"]', + cardLinkClass: 'a[href*="/novel/"]', + cardTitleClass: '.novel-title, h3, h4', + titleAttr: 'title', + imageClass: 'img', + imageAttr: 'src', + imageFallbackAttr: 'data-src', + ratingClass: '.rating, span:contains("★")', + chaptersClass: 'span:contains("Chapters"), .chapters', + statusClass: '.status', + genresClass: '.genres a, .tags a', + }, + }, +}; + +export const NovelDetailPageClasses = { + novelfire: { + // Main container + detailsContainer: '.novel-info, .header-body, .novel-header', + + // Title + title: 'h1.novel-title, h1', + + // Cover image + imgSrc: '.novel-image img, .cover img, .novel-cover img', + getImageAttr: 'src', + + // Author - novelfire uses itemprop + author: '.author span[itemprop="author"], a[href*="/author/"], .author a', + + // Rating + rating: 'strong.nub, .rating-value, .novel-rating', + ratingMeta: 'meta[itemprop="ratingValue"]', + + // Stats + chaptersCount: '.stats:contains("Chapters"), strong:contains("Chapters")', + chaptersIcon: 'i.icon-book-open', + views: '.stats:contains("K"), strong:has(i.icon-eye)', + viewsIcon: 'i.icon-eye', + bookmarked: 'strong:has(i.icon-bookmark)', + bookmarkedIcon: 'i.icon-bookmark', + + // Status + status: 'strong.ongoing, strong.completed, strong.hiatus, .novel-status', + statusClass: { + ongoing: 'ongoing', + completed: 'completed', + hiatus: 'hiatus', + }, + + // Summary + summary: '.content.expand-wrapper, .summary, .description, .novel-summary', + summaryParagraphs: 'p', + + // Genres - used by parseNovelGenres + genre: 'a.property-item[href*="/genre-"], .genres a, .novel-genres a', + genres: 'a.property-item[href*="/genre-"], .genres a, .novel-genres a', + + // Details section (table/dl structure) + detailsDL: 'dt, .detail-label, .info-label', + detailsValue: 'dd, .detail-value, .info-value', + + // Chapter list (on detail page) + chaptersList: 'a[href*="/chapter-"]', + chapterTitle: '', + chapterLink: '', + chapterNumberPattern: 'chapter-(\\d+)', + + // Alternative selectors for different page layouts + alternative: { + title: 'h1', + image: 'img[alt*="cover"], img.cover', + author: 'a[href*="/author/"]', + summary: '.novel-summary, .synopsis', + }, + }, + wtrlab: { + // Main container + detailsContainer: '.novel-details, main, .content', + + // Title - WTR-Lab has English and Chinese titles + title: 'h1, .novel-title', + alternateTitle: '.alternate-title, h2', + + // Cover image + imgSrc: '.cover img, .novel-cover img, img[alt*="cover"]', + getImageAttr: 'src', + + // Author + author: 'a[href*="/author/"], .author a', + + // Status + status: '.status, span:contains("Ongoing"), span:contains("Completed")', + + // Stats + views: 'span:contains("views"), .views', + chaptersCount: 'span:contains("Chapters"), .chapters', + charactersCount: 'span:contains("Characters"), .characters', + readersCount: 'span:contains("Readers"), .readers', + + // Rating + rating: '.rating, span:contains("★")', + reviewCount: '.reviews, span:contains("reviews")', + + // Summary + summary: '.summary, .description, .novel-summary', + summaryParagraphs: 'p', + + // Genres & Tags + genre: '.genres a, .tags a, a[href*="/novel-list?genre="]', + genres: '.genres a, .tags a, a[href*="/novel-list?genre="]', + tags: '.tags a, .protagonist-archetypes a', + + // Meta information + dateAdded: 'span:contains("DATE ADDED"), .date-added', + requestedBy: 'a[href*="/profile/"]', + releasedBy: 'a[href*="/profile/"]', + + // Rankings + rankings: '.rankings a, a[href*="/ranking/"]', + + // Chapter list + chaptersList: '.chapter-list a, a[href*="/chapter-"]', + chapterPagination: '.pagination', + + // Similar novels + similarNovels: '.similar-novels a[href*="/novel/"]', + + // Details section + detailsDL: 'dt, .detail-label, .info-label', + detailsValue: 'dd, .detail-value, .info-value', + }, +}; + +export const NovelChapterPageClasses = { + novelfire: { + // Chapter title + title: 'h4, h1.chapter-title, span.chapter-title', + + // Content container + contentContainer: '#content, .chapter-content, article', + + // Paragraphs within content + paragraphs: 'p', + + // Navigation + prevChapter: 'a.prevchap:not(.isDisabled), a.prev:not(.isDisabled), a[rel="prev"]', + nextChapter: 'a.nextchap:not(.isDisabled), a.next:not(.isDisabled), a[rel="next"]', + + // Disabled navigation (first/last chapter) + prevDisabled: 'a.prevchap.isDisabled, a.prev.isDisabled', + nextDisabled: 'a.nextchap.isDisabled, a.next.isDisabled', + + // Chapter info + chapterInfo: '.chapter-info, .chapter-header', + novelTitle: '.novel-title, a[href*="/book/"]', + + // Settings/controls + fontSizeControls: '.font-size-controls', + themeControls: '.theme-controls', + + // Alternative content selectors + alternativeContent: { + contentDiv: 'div[id="content"]', + articleContent: 'article', + chapterDiv: '.chapter-body', + textContent: '.text-content', + }, + + // Exclude from content + excludeFromContent: 'script, style, .ads, .advertisement, .comments', + }, + wtrlab: { + // Chapter title + title: 'h1, h2, .chapter-title', + + // Content container + contentContainer: '.chapter-content, article, .content', + + // Paragraphs within content + paragraphs: 'p', + + // Navigation + prevChapter: 'a:contains("Prev"), a.prev, a[rel="prev"]', + nextChapter: 'a:contains("Next"), a.next, a[rel="next"]', + + // Progress indicator + progress: '.progress, span:contains("/")', + + // Reading mode selector (for reference, not parsed) + modeSelector: '.mode-selector, .service-selector', + + // Exclude from content + excludeFromContent: 'script, style, .ads, .advertisement, .comments, .popup', + }, +}; + +export const NovelSearchPageClasses = { + novelfire: { + // Search results container + resultsContainer: '.search-results, .novel-list', + + // Individual result items + cardClass: 'li.novel-item', + + // Card elements + cardLinkClass: 'a[href*="/book/"]', + cardTitleClass: 'a[href*="/book/"]', + titleAttr: 'title', + + // Image + imageClass: 'img', + imageAttr: 'data-src', + imageFallbackAttr: 'src', + + // Metadata + rankClass: '.rank, span:contains("Rank")', + chaptersClass: 'span:contains("Chapters")', + ratingClass: '.badge._br', + ratingIcon: 'i.icon-star', + + // Pagination + pagination: '.pagination', + paginationLinks: 'a[href*="?page="]', + currentPage: '.pagination .active, .pagination .current', + nextPage: 'a:contains("Next"), a:contains("»")', + prevPage: 'a:contains("Prev"), a:contains("«")', + + // No results + noResults: '.no-results, .empty-state', + }, + wtrlab: { + // Search results container + resultsContainer: '.search-results, .novel-list', + + // Individual result items + cardClass: '.novel-item, a[href*="/novel/"]', + + // Card elements + cardLinkClass: 'a[href*="/novel/"]', + cardTitleClass: '.novel-title, h3, h4', + titleAttr: 'title', + + // Image + imageClass: 'img', + imageAttr: 'src', + imageFallbackAttr: 'data-src', + + // Metadata + statusClass: '.status', + chaptersClass: 'span:contains("Chapters")', + viewsClass: 'span:contains("views")', + ratingClass: '.rating', + genresClass: '.genres a', + + // Pagination + pagination: '.pagination', + paginationLinks: 'a[href*="?page="]', + currentPage: '.pagination .active, .pagination .current', + nextPage: 'a:contains("Next"), a:contains("»")', + prevPage: 'a:contains("Prev"), a:contains("«")', + + // No results + noResults: '.no-results, .empty-state', + }, +}; + +export const NovelChapterListPageClasses = { + novelfire: { + // Chapter list container + chaptersContainer: '.chapter-list, .chapters', + + // Individual chapters + chapterItem: 'a[href*="/chapter-"]', + chapterLink: '', + chapterTitle: '', + + // Chapter number extraction from URL + chapterNumberPattern: 'chapter-(\\d+)', + + // Pagination + pagination: '.pagination', + paginationLinks: 'a[href*="?page="]', + currentPage: '.pagination .active, .pagination .current', + nextPage: 'a[href*="?page="]:contains("Next"), a:contains("»")', + prevPage: 'a[href*="?page="]:contains("Prev"), a:contains("«")', + + // Total chapters info + totalInfo: '.total-chapters, .chapter-count', + }, + wtrlab: { + // Chapter list container - WTR-Lab TOC tab has chapters in a table or list + chaptersContainer: 'table, .chapter-list, .chapters, tbody', + + // Individual chapters - WTR-Lab uses links with /chapter-{id}-{number} + chapterItem: 'a[href*="/chapter-"], tr a[href*="/chapter-"]', + chapterLink: '', + chapterTitle: '', + + // Chapter number extraction from URL + // WTR-Lab format: /en/novel/{id}/{slug}/chapter-{chapterId}-{chapterNumber} + chapterNumberPattern: 'chapter-\\d+-(\\d+)', + + // Pagination - WTR-Lab TOC may have pagination + pagination: '.pagination, nav', + paginationLinks: 'a[href*="?page="], a[href*="tab=toc"]', + currentPage: '.pagination .active, .pagination .current', + nextPage: 'a:contains("Next"), a:contains("»"), a[href*="page="]:not(.active)', + prevPage: 'a:contains("Prev"), a:contains("«")', + + // Total chapters info + totalInfo: '.total-chapters, .chapter-count', + }, +}; + +export const NovelRankingPageClasses = { + novelfire: { + // Ranking table + rankingContainer: 'table, .ranking-list', + + // Individual rows + rowClass: 'tr', + + // Row elements + novelLink: 'a[href*="/book/"]', + title: 'a[href*="/book/"]', + image: 'img', + imageAttr: 'data-src', + + // Rank (usually row index + 1) + rankClass: '.rank, td:first-child', + + // Stats + views: '.views, td:contains("K"), td:contains("M")', + rating: '.rating, .badge._br', + chapters: '.chapters', + }, + wtrlab: { + // Ranking container + rankingContainer: '.ranking-list, table, .novel-list', + + // Ranking type tabs + rankingTabs: '.ranking-type a, .tabs a', + dailyTab: 'a[href*="/ranking/daily"]', + weeklyTab: 'a[href*="/ranking/weekly"]', + monthlyTab: 'a[href*="/ranking/monthly"]', + allTimeTab: 'a[href*="/ranking/all_time"]', + + // Individual rows + rowClass: 'tr, .ranking-item, .novel-item', + + // Row elements + novelLink: 'a[href*="/novel/"]', + title: 'a[href*="/novel/"], .novel-title', + image: 'img', + imageAttr: 'src', + + // Rank (usually row index + 1) + rankClass: '.rank, td:first-child, .ranking-number', + + // Stats + views: '.views, span:contains("view")', + rating: '.rating, span:contains("★")', + chapters: '.chapters, span:contains("Chapters")', + readers: '.readers, span:contains("Readers")', + + // Status + status: '.status, span:contains("Ongoing"), span:contains("Completed")', + + // Pagination + pagination: '.pagination', + }, +}; + +// Base URL for novelfire.net +export const NOVELFIRE_BASE = 'https://novelfire.net'; + +// Image URL patterns +export const ImagePatterns = { + novelfire: { + // novelfire uses server-1, server-2, etc. for images + serverPattern: /server-\d+/, + // Image extensions + extensions: ['jpg', 'jpeg', 'png', 'webp'], + // Handle relative URLs + makeAbsolute: (url) => { + if (!url) { + return null; + } + if (url.startsWith('http')) { + return url; + } + if (url.startsWith('/')) { + return `${NOVELFIRE_BASE}${url}`; + } + return `${NOVELFIRE_BASE}/${url}`; + }, + }, +}; + +// Status mappings +export const NovelStatus = { + ongoing: 'Ongoing', + completed: 'Completed', + hiatus: 'Hiatus', +}; + +// Default values +export const Defaults = { + status: 'Ongoing', + rating: null, + chapters: null, + views: null, + bookmarked: null, +}; diff --git a/src/Screens/Novel/APIs/index.js b/src/Screens/Novel/APIs/index.js new file mode 100644 index 00000000..96e16741 --- /dev/null +++ b/src/Screens/Novel/APIs/index.js @@ -0,0 +1,34 @@ +/** + * Novel API Exports + * All functions support optional hostKey parameter (default: 'novelfire') + */ + +// Home API +export { getNovelHome, getNovelsByGenre, getLatestNovels, getCompletedNovels } from './Home'; + +// Details API +export { getNovelDetails, getChapterList, getHostKeyFromLink as getNovelHostKeyFromLink } from './Details'; + +// Reader API +export { getNovelChapter, getMultipleChapters, getHostKeyFromLink } from './Reader'; + +// Search API +export { searchNovels, searchByAuthor } from './Search'; + +// Legacy parsers (kept for backward compatibility) +export { + parseNovelHome, + parseNovelDetails, + parseNovelChapter, + parseSearchResults, + parseRanking, +} from './novelParser'; + +// Source configuration +export { NovelHostName } from '../../../Utils/APIs'; +export { + NovelHomePageClasses, + NovelDetailPageClasses, + NovelChapterPageClasses, + NovelSearchPageClasses, +} from './constance'; \ No newline at end of file diff --git a/src/Screens/Novel/APIs/novelParser.js b/src/Screens/Novel/APIs/novelParser.js new file mode 100644 index 00000000..0e14fd57 --- /dev/null +++ b/src/Screens/Novel/APIs/novelParser.js @@ -0,0 +1,586 @@ +/** + * NovelFire.net HTML Parser + * Parses HTML responses from novelfire.net + */ + +const NOVELFIRE_BASE = 'https://novelfire.net'; + +/** + * Extract text content from HTML string + */ +const extractText = (html, pattern, groupIndex = 1) => { + const match = html.match(pattern); + return match ? match[groupIndex]?.trim() : null; +}; + +/** + * Extract all matches from HTML string + */ +const extractAllMatches = (html, pattern) => { + const matches = []; + let match; + const regex = new RegExp(pattern.source, pattern.flags); + while ((match = regex.exec(html)) !== null) { + matches.push(match); + } + return matches; +}; + +/** + * Clean HTML entities and tags from text + */ +const cleanText = (text) => { + if (!text) return ''; + return text + .replace(/ /g, ' ') + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, ' ') + .trim(); +}; + +/** + * Parse novel card from HTML + */ +const parseNovelCard = (cardHtml) => { + if (!cardHtml) return null; + + const linkMatch = cardHtml.match(/href="([^"]*\/book\/[^"]+)"/); + + // Handle both src and data-src for lazy loading, and relative URLs + let coverImage = null; + const imageMatch = cardHtml.match(/data-src="([^"]+\.(?:jpg|jpeg|png|webp)[^"]*)"/i) || + cardHtml.match(/src="([^"]+\.(?:jpg|jpeg|png|webp)[^"]*)"/i); + if (imageMatch?.[1]) { + // Handle relative URLs + if (imageMatch[1].startsWith('/')) { + coverImage = `${NOVELFIRE_BASE}${imageMatch[1]}`; + } else if (imageMatch[1].startsWith('http')) { + coverImage = imageMatch[1]; + } else { + coverImage = `${NOVELFIRE_BASE}/${imageMatch[1]}`; + } + } + + const titleMatch = cardHtml.match(/title="([^"]+)"/) || + cardHtml.match(/]*class="[^"]*novel-title[^"]*"[^>]*>([^<]+)<\/h[34]>/i) || + cardHtml.match(/]*>([^<]+)<\/h[34]>/); + + // Rating from badge: 4.7 + const ratingMatch = cardHtml.match(/<\/i>(\d+\.?\d*)/i) || + cardHtml.match(/rating["\s:]+(\d+\.?\d*)/i) || + cardHtml.match(/(\d+\.?\d*)\s*]*class="[^"]*star[^"]*"/i); + + // Chapters: 238 Chapters + const chaptersMatch = cardHtml.match(/(\d+)\s*Chapters/i) || + cardHtml.match(/(\d+)\s*chapters/i); + + const authorMatch = cardHtml.match(/author["\s:]+([^"<,]+)/i) || + cardHtml.match(/]*class="[^"]*author[^"]*"[^>]*>([^<]+)<\/span>/i); + + const statusMatch = cardHtml.match(/status["\s:]+(\w+)/i) || + cardHtml.match(/]*class="[^"]*status[^"]*"[^>]*>([^<]+)<\/span>/i); + + return { + title: cleanText(titleMatch?.[1]), + link: linkMatch ? `${NOVELFIRE_BASE}${linkMatch[1]}` : null, + coverImage, + author: cleanText(authorMatch?.[1]), + rating: ratingMatch ? parseFloat(ratingMatch[1]) : null, + chapters: chaptersMatch ? parseInt(chaptersMatch[1], 10) : null, + status: cleanText(statusMatch?.[1]) || 'Ongoing', + }; +}; + +/** + * Parse home page sections + */ +export const parseNovelHome = (html) => { + if (!html) return null; + + const sections = []; + + // Helper function to parse novels from a section + const parseNovelsFromSection = (sectionHtml) => { + const novels = []; + + // Find all novel items:
  • ...
  • + const novelItemPattern = /]*class="[^"]*novel-item[^"]*"[^>]*>([\s\S]*?)<\/li>/gi; + const novelMatches = extractAllMatches(sectionHtml, novelItemPattern); + + novelMatches.forEach((match) => { + const novel = parseNovelCard(match[1]); + if (novel?.title && novel?.link) { + novels.push(novel); + } + }); + + // Also try finding novel links directly + if (novels.length === 0) { + const novelLinkPattern = /]*href="\/book\/[^"]+"[^>]*>([\s\S]*?)<\/a>/gi; + const linkMatches = extractAllMatches(sectionHtml, novelLinkPattern); + + linkMatches.forEach((match) => { + const novel = parseNovelCard(match[0]); + if (novel?.title && novel?.link) { + novels.push(novel); + } + }); + } + + return novels; + }; + + // Parse Recommends section + const recommendsMatch = html.match(/]*>Recommends<\/h3>[\s\S]*?
    ([\s\S]*?)<\/div>\s*<\/section>/i); + if (recommendsMatch) { + const novels = parseNovelsFromSection(recommendsMatch[1]); + if (novels.length > 0) { + sections.push({ name: 'Recommends', novels }); + } + } + + // Parse Ranking section (contains Most Read, New Trend, User Rated tabs) + const rankingMatch = html.match(/

    Most Read<\/span><\/h3>([\s\S]*?)

    /i); + if (rankingMatch) { + const novels = parseNovelsFromSection(rankingMatch[1]); + if (novels.length > 0) { + sections.push({ name: 'Most Read', novels }); + } + } + + // Parse New Trend tab + const newTrendMatch = html.match(/

    New Trend<\/span><\/h3>([\s\S]*?)(?:

    |<\/div>\s*<\/div>)/i); + if (newTrendMatch) { + const novels = parseNovelsFromSection(newTrendMatch[1]); + if (novels.length > 0) { + sections.push({ name: 'New Trend', novels }); + } + } + + // Parse User Rated tab + const userRatedMatch = html.match(/

    User Rated<\/span><\/h3>([\s\S]*?)(?:

    |<\/div>\s*<\/div>)/i); + if (userRatedMatch) { + const novels = parseNovelsFromSection(userRatedMatch[1]); + if (novels.length > 0) { + sections.push({ name: 'User Rated', novels }); + } + } + + // Parse Latest Novels section + const latestMatch = html.match(/]*>Latest Novels<\/h3>[\s\S]*?
    ]*>([\s\S]*?)<\/div>\s*<\/section>/i); + if (latestMatch) { + const novels = parseNovelsFromSection(latestMatch[1]); + if (novels.length > 0) { + sections.push({ name: 'Latest Novels', novels }); + } + } + + // Parse Completed Stories section + const completedMatch = html.match(/]*>Completed Stories<\/h3>[\s\S]*?
    ]*>([\s\S]*?)<\/div>\s*<\/section>/i); + if (completedMatch) { + const novels = parseNovelsFromSection(completedMatch[1]); + if (novels.length > 0) { + sections.push({ name: 'Completed Stories', novels }); + } + } + + // Fallback: If no sections found, try to parse all novels on page + if (sections.length === 0) { + const allNovels = []; + const novelItemPattern = /]*class="[^"]*novel-item[^"]*"[^>]*>([\s\S]*?)<\/li>/gi; + const novelMatches = extractAllMatches(html, novelItemPattern); + + novelMatches.forEach((match) => { + const novel = parseNovelCard(match[1]); + if (novel?.title && novel?.link) { + allNovels.push(novel); + } + }); + + if (allNovels.length > 0) { + sections.push({ name: 'Featured', novels: allNovels.slice(0, 20) }); + } + } + + return sections.length > 0 ? sections : null; +}; + +/** + * Parse novel details page + */ +export const parseNovelDetails = (html) => { + if (!html) return null; + + // Extract title + const titleMatch = html.match(/]*class="[^"]*novel-title[^"]*"[^>]*>([^<]+)<\/h1>/i) || + html.match(/]*>([^<]+)<\/h1>/i); + + // Extract author - novelfire uses:
    Author: + const authorMatch = html.match(/
    [\s\S]*?