This guide will walk you through creating a new extension for the NovelLibrary Android app.
- Android Studio installed
- Basic knowledge of Kotlin
- Understanding of web scraping concepts (HTML parsing with Jsoup)
- Familiarity with HTTP requests
Extensions allow NovelLibrary to fetch novels from different websites. Each extension implements methods to:
- Search for novels
- Fetch novel details
- Retrieve chapter lists
- Parse chapter content
Before creating an extension, identify these key URLs:
- Search URL - Takes a search query parameter and returns results
- Novel Details URL - Shows information about a specific novel
- Chapter List URL - Returns all chapters for a novel
- Chapter Content URL - Contains the actual chapter text
Example for a site example.com:
- Search:
https://example.com/search?q=novel+name - Novel:
https://example.com/novel/novel-slug - Chapters:
https://example.com/novel/novel-slug/chapters - Chapter:
https://example.com/novel/novel-slug/chapter-1
Navigate to extensions/individual/en/ (or appropriate language folder) and create your extension folder.
extensions/individual/en/yoursite/
├── AndroidManifest.xml
├── build.gradle
├── res/
│ ├── mipmap-*/
│ │ └── ic_launcher.png
│ └── values/
│ └── ic_launcher_background.xml
└── src/
└── io/github/gmathi/novellibrary/extension/en/yoursite/
└── YourSite.kt
The easiest way is to copy an existing extension:
cp -r extensions/individual/en/boxnovel extensions/individual/en/yoursiteEdit extensions/individual/en/yoursite/build.gradle:
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
ext {
extName = 'YourSite' // Display name
pkgNameSuffix = 'en.yoursite' // Package suffix
extClass = '.YourSite' // Main class name
extVersionCode = 1 // Version code (increment on updates)
libVersion = '1.0' // Library version
}
android {
namespace = 'io.github.gmathi.novellibrary.extension.en.yoursite'
}
apply from: "$rootDir/common.gradle"The manifest is minimal:
<?xml version="1.0" encoding="utf-8"?>
<manifest package="io.github.gmathi.novellibrary.extension" />- Rename the folder:
src/io/github/gmathi/novellibrary/extension/en/boxnovel/→yoursite/ - Rename the Kotlin file:
BoxNovel.kt→YourSite.kt
Edit YourSite.kt:
package io.github.gmathi.novellibrary.extension.en.yoursite
import io.github.gmathi.novellibrary.model.database.Novel
import io.github.gmathi.novellibrary.model.database.WebPage
import io.github.gmathi.novellibrary.model.source.filter.FilterList
import io.github.gmathi.novellibrary.model.source.online.ParsedHttpSource
import io.github.gmathi.novellibrary.network.GET
import io.github.gmathi.novellibrary.network.POST
import io.github.gmathi.novellibrary.util.Exceptions.NOT_USED
import io.github.gmathi.novellibrary.util.network.asJsoup
import okhttp3.*
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import java.net.URLEncoder
class YourSite : ParsedHttpSource() {
override val baseUrl: String = "https://yoursite.com"
override val lang: String = "en"
override val supportsLatest: Boolean = true
override val name: String = "Your Site"
override val client: OkHttpClient
get() = network.cloudflareClient
override fun headersBuilder(): Headers.Builder = Headers.Builder()
.add("User-Agent", USER_AGENT)
.add("Referer", baseUrl)
// Implement required methods below...
companion object {
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.193 Safari/537.36"
}
}override fun searchNovelsRequest(page: Int, query: String, filters: FilterList): Request {
val encodedQuery = URLEncoder.encode(query, "UTF-8")
val url = "$baseUrl/search?q=$encodedQuery&page=$page"
return GET(url, headers)
}
override fun searchNovelsSelector() = "div.novel-item"
override fun searchNovelsFromElement(element: Element): Novel {
val titleElement = element.selectFirst("h3.title a")!!
val novel = Novel(titleElement.text(), titleElement.attr("abs:href"), id)
novel.imageUrl = element.selectFirst("img")?.attr("abs:src")
novel.rating = element.selectFirst("span.rating")?.text()
return novel
}
override fun searchNovelsNextPageSelector() = "a.next-page"override fun novelDetailsParse(novel: Novel, document: Document): Novel {
novel.imageUrl = document.selectFirst("div.novel-cover img")?.attr("abs:src")
novel.longDescription = document.selectFirst("div.description")?.text()
novel.rating = document.selectFirst("span.rating-value")?.text()
novel.authors = document.select("div.author a").map { it.text() }
novel.genres = document.select("div.genres a").map { it.text() }
return novel
}override fun chapterListRequest(novel: Novel): Request {
return GET("${novel.url}/chapters", headers)
}
override fun chapterListSelector() = "ul.chapter-list li a"
override fun chapterFromElement(element: Element) =
WebPage(element.absUrl("href"), element.text())
override fun chapterListParse(novel: Novel, response: Response): List<WebPage> {
val document = response.asJsoup()
return document.select(chapterListSelector()).mapIndexed { index, element ->
val chapter = chapterFromElement(element)
chapter.orderId = index.toLong()
chapter
}
}If you don't support certain features, stub them:
override fun latestUpdatesRequest(page: Int): Request = throw Exception(NOT_USED)
override fun latestUpdatesSelector(): String = throw Exception(NOT_USED)
override fun latestUpdatesFromElement(element: Element): Novel = throw Exception(NOT_USED)
override fun latestUpdatesNextPageSelector(): String = throw Exception(NOT_USED)
override fun popularNovelsRequest(page: Int): Request = throw Exception(NOT_USED)
override fun popularNovelsSelector(): String = throw Exception(NOT_USED)
override fun popularNovelsFromElement(element: Element): Novel = throw Exception(NOT_USED)
override fun popularNovelNextPageSelector(): String = throw Exception(NOT_USED)Add your extension to the root settings.gradle:
include ':extensions:individual:en:yoursite'# Build all extensions
./gradlew assembleRelease
# Build specific extension
./gradlew :extensions:individual:en:yoursite:assembleReleaseThe APK will be generated in:
extensions/individual/en/yoursite/build/outputs/apk/release/
Use the testing scripts:
# Test runtime functionality
python test-extension-runtime.py
# Validate structure
pwsh validate-extension-structure.ps1Use browser DevTools to find the right selectors:
- Right-click element → Inspect
- Note the class names and structure
- Test selectors in browser console:
document.querySelector("your.selector")
Always use safe calls (?.) when selecting elements:
novel.imageUrl = element.selectFirst("img")?.attr("abs:src")If the site uses Cloudflare, use the cloudflare client:
override val client: OkHttpClient
get() = network.cloudflareClientFor AJAX chapter loading:
override fun chapterListRequest(novel: Novel): Request {
val formBody = FormBody.Builder()
.add("action", "get_chapters")
.add("novel_id", novel.externalNovelId!!)
.build()
return POST("${novel.url}/ajax/chapters", headers, formBody)
}Store additional data in the novel's metadata:
novel.metadata["PostId"] = document.select("input#post-id").attr("value")
novel.externalNovelId = postIdAdd logging to help debug:
println("DEBUG: Search URL = $url")
println("DEBUG: Found ${elements.size} results")- Check if the selector matches the HTML structure
- Verify the search URL format
- Check if the site requires authentication
- Use
attr("abs:src")instead ofattr("src")for absolute URLs - Check if images require referer header
- Use
.reversed()if chapters are listed newest-first - Set
orderIdto maintain correct order
- Use
network.cloudflareClientinstead of default client - Add proper User-Agent and Referer headers
See extensions/individual/en/boxnovel/ for a complete working example.
- Jsoup Documentation - HTML parsing
- OkHttp Documentation - HTTP client
- CSS Selectors Reference
- Check existing extensions for similar site structures
- Review TESTING-GUIDE.md for testing procedures
- Check EXTENSIONS-STATUS.md for working examples