From 45be3eb6e2e8c2e4c306ffdafb5a3b3a8eb6bf10 Mon Sep 17 00:00:00 2001 From: Josh Lintag <11928273+jlintag@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:24:46 -0400 Subject: [PATCH 1/5] feat: add en.hentai2read Hentai2Read was rebuilt as an Inertia.js app, so every page embeds its data as JSON in a script tag. The source reads that instead of scraping markup. - Browse listings, filters (type, status, sort, tags incl. categories, match-any) and an artist text filter - Searches that exactly name a tag, artist or parody include that name's books alongside title matches, most specific results first - Tapped tags, artists and parodies on a series page search by them; description links open artist, parody, character and publisher pages - Details: status, artists, rating, views, rank, year, alt title; completed series skip library updates - Chapters with local-day dates; members-only chapters marked locked - Deep links for series, chapters and tag pages - Keys from the previous en.hentai2read source still resolve through the site's redirects --- sources/en.hentai2read/.cargo/config.toml | 5 + sources/en.hentai2read/Cargo.lock | 366 +++++++++ sources/en.hentai2read/Cargo.toml | 25 + sources/en.hentai2read/res/filters.json | 880 ++++++++++++++++++++++ sources/en.hentai2read/res/icon.png | Bin 0 -> 11197 bytes sources/en.hentai2read/res/source.json | 42 ++ sources/en.hentai2read/src/lib.rs | 337 +++++++++ sources/en.hentai2read/src/models.rs | 305 ++++++++ sources/en.hentai2read/src/search.rs | 188 +++++ 9 files changed, 2148 insertions(+) create mode 100644 sources/en.hentai2read/.cargo/config.toml create mode 100644 sources/en.hentai2read/Cargo.lock create mode 100644 sources/en.hentai2read/Cargo.toml create mode 100644 sources/en.hentai2read/res/filters.json create mode 100644 sources/en.hentai2read/res/icon.png create mode 100644 sources/en.hentai2read/res/source.json create mode 100644 sources/en.hentai2read/src/lib.rs create mode 100644 sources/en.hentai2read/src/models.rs create mode 100644 sources/en.hentai2read/src/search.rs diff --git a/sources/en.hentai2read/.cargo/config.toml b/sources/en.hentai2read/.cargo/config.toml new file mode 100644 index 000000000..f137b5a99 --- /dev/null +++ b/sources/en.hentai2read/.cargo/config.toml @@ -0,0 +1,5 @@ +[build] +target = "wasm32-unknown-unknown" + +[target.wasm32-unknown-unknown] +runner = "aidoku-test-runner" diff --git a/sources/en.hentai2read/Cargo.lock b/sources/en.hentai2read/Cargo.lock new file mode 100644 index 000000000..f61fb1caf --- /dev/null +++ b/sources/en.hentai2read/Cargo.lock @@ -0,0 +1,366 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aidoku" +version = "0.3.0" +source = "git+https://github.com/Aidoku/aidoku-rs.git#ae6485e3c14ecd1fd5497d170eae884c68d04a87" +dependencies = [ + "euclid", + "hashbrown", + "itoa", + "num-traits", + "paste", + "postcard", + "serde", + "serde_json", + "talc", + "thiserror", +] + +[[package]] +name = "aidoku-test" +version = "1.0.0" +source = "git+https://github.com/Aidoku/aidoku-rs.git#ae6485e3c14ecd1fd5497d170eae884c68d04a87" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin", + "stable_deref_trait", +] + +[[package]] +name = "hentai2read" +version = "0.1.0" +dependencies = [ + "aidoku", + "aidoku-test", + "serde", + "serde_json", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "talc" +version = "4.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3ae828aa394de34c7de08f522d1b86bd1c182c668d27da69caadda00590f26d" +dependencies = [ + "lock_api", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.7", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/sources/en.hentai2read/Cargo.toml b/sources/en.hentai2read/Cargo.toml new file mode 100644 index 000000000..1897d6b4c --- /dev/null +++ b/sources/en.hentai2read/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "hentai2read" +version = "0.1.0" +edition = "2024" + +[dependencies] +aidoku = { git = "https://github.com/Aidoku/aidoku-rs.git", version = "0.3.0", features = ["json"] } +serde = { version = "1.0", default-features = false, features = ["derive", "alloc"] } +serde_json = { version = "1.0", default-features = false, features = ["alloc"] } + +[dev-dependencies] +aidoku = { git = "https://github.com/Aidoku/aidoku-rs.git", features = ["test"] } +aidoku-test = { git = "https://github.com/Aidoku/aidoku-rs.git" } + +[lib] +crate-type = ["cdylib"] + +[profile.dev] +panic = "abort" + +[profile.release] +panic = "abort" +opt-level = "s" +strip = true +lto = true diff --git a/sources/en.hentai2read/res/filters.json b/sources/en.hentai2read/res/filters.json new file mode 100644 index 000000000..bb3ece0d1 --- /dev/null +++ b/sources/en.hentai2read/res/filters.json @@ -0,0 +1,880 @@ +[ + { + "type": "text", + "id": "author", + "title": "Artist", + "placeholder": "Artist name" + }, + { + "type": "select", + "id": "type", + "title": "Type", + "options": [ + "All", + "Original", + "Doujinshi", + "Image set" + ], + "ids": [ + "", + "original", + "doujinshi", + "imageset" + ] + }, + { + "type": "select", + "id": "status", + "title": "Status", + "options": [ + "Any", + "Ongoing", + "Completed" + ], + "ids": [ + "", + "ongoing", + "completed" + ] + }, + { + "type": "sort", + "id": "sort", + "title": "Sort", + "canAscend": false, + "options": [ + "Relevance", + "Recently updated", + "Newest", + "Most read", + "Highest rated", + "A to Z" + ] + }, + { + "type": "check", + "id": "any", + "title": "Tag matching", + "name": "Match any included tag" + }, + { + "type": "multi-select", + "id": "tags", + "title": "Tags", + "isGenre": true, + "canExclude": true, + "usesTagStyle": true, + "options": [ + "Abortion", + "Absent Parents", + "Abusive", + "Abusive Lover", + "Adapted to H-Anime", + "Addiction", + "Adopted Sister", + "Adoption", + "Adoptive Siblings", + "Adult", + "Adultery", + "Affair", + "Aggressive Lover", + "Ahegao", + "Airheads", + "All-Girls School", + "Alternative Ending", + "Anal", + "Anal Play", + "Analingus (Rimjob)", + "Angels", + "Animal Girls", + "Animal Transformation", + "Anthology", + "Anthropomorphism", + "Apron", + "Armpit Licking", + "Armpit Sex", + "Arranged Marriage", + "Artificial Intelligence", + "Assjob", + "Aunt-Nephew Relationship", + "Aunts", + "Authority Figures", + "Bad Grammar", + "Bathroom Intercourse", + "BBW", + "BDSM", + "Beach", + "Best Friends", + "Bestiality", + "Betrayal", + "Big Ass", + "Big Breasts", + "Bikini", + "Bishoujo", + "Bittersweet Ending", + "Blackmail", + "Blind Characters", + "Blindfold", + "Bloomers", + "Blow job", + "Blowjob Face", + "Body Modification", + "Body Swap", + "Body Writing", + "Bodysuit", + "Bondage", + "Borderline H", + "Brainwash", + "Breast Expansion", + "Brides", + "Brother and Sister", + "Brother Complex", + "Brother-in-law", + "BSDM", + "Bukkake", + "Bullying", + "Bunny Girls", + "Cannibalism", + "Cat Ears", + "Cat Girls", + "Caught in the Act", + "Censored", + "Cervix Penetration", + "CGs", + "Character Who Bullies the One They Love", + "Cheating", + "Cheerleaders", + "Chikan", + "Child Abuse", + "Child Born From Incest", + "Childhood Friends", + "Childhood Love", + "Chinese Dress", + "Chubby", + "Club President", + "Clumsy Character", + "Co-workers", + "Collection of Inter-Linked Stories", + "Collection of Short Stories/Oneshots", + "Comedy", + "Compilation", + "Confession", + "Corruption", + "Cosplay", + "Cousins", + "Cow Girls", + "Creampie", + "Crossdressing", + "Cunnilingus", + "Dakara Boku wa H ga Dekinai", + "Dark Skin", + "Debt-Motivated Prostitution", + "Debts", + "Deception", + "Deep Throat", + "Defloration", + "Delinquents", + "Demon Girls", + "Demon Hunters", + "Demons", + "Doctor-Patient Relationship", + "Dog Girls", + "Double Penetration", + "Doujinshi", + "Drugs", + "Drunk", + "Drunk Intercourse", + "Ecchi", + "Elf-Elves", + "Emotionless Sex", + "Enema Play", + "Enemies Become Lovers", + "Ero-Guro", + "Exhibitionism", + "Facesitting", + "Fairy-Fairies", + "Family Love", + "Family Secrets", + "Father and Daughter", + "Father-in-Law", + "Female Dominance", + "Fetish", + "Fight Between Lovers", + "Fingering", + "First Love", + "Fisting", + "Foot job", + "Foot Licking", + "Forced into a Relationship", + "Forced Marriage", + "Forced Sex", + "Foursome", + "Fox Girls", + "Friends Become Lovers", + "Full Color", + "Furry", + "Futa on Female", + "Futa on Male", + "Futanari", + "Gang Rape", + "Gangbang", + "Ganguro", + "Gender Bender", + "Ghost", + "Giantess", + "Girls Only", + "Glasses", + "God-Human Relationship", + "Goddess", + "Group Intercourse", + "Gyaru", + "Hairy Armpit", + "Hand Job", + "Happy Sex", + "Hardcore", + "Harem", + "Harem-seeking Male Lead", + "Hot Springs", + "Housewife-Housewives", + "Human Pet", + "Human Toilets", + "Human-Nonhuman Relationship", + "Humiliation", + "Hypnotism", + "Idols", + "Impregnation", + "Incest", + "Incest as a Subplot", + "Infidelity", + "Inflation", + "Insect", + "Inverted Nipples", + "Isekai (Otherworld)", + "Jealous Lover", + "Jealousy", + "Kidnapping", + "Kimono", + "Korean Comic", + "Kunoichi (Ninja Girls)", + "Lactation", + "Large Dicks", + "Leotard", + "Licensed", + "Lingerie", + "Little Sisters", + "Live-in Lover", + "Lizard Girl", + "Lolicon", + "Love At First Sight", + "Love Rivals", + "Love Triangles", + "Magic", + "Magical Girls", + "Maids", + "Male Dominance", + "Mangaka", + "Masochists", + "Master-Pet Relationship", + "Master-Servant Relationship", + "Master-Slave Relationship", + "Masturbation", + "Mermaids", + "MILFs", + "Mind Break", + "Mind Control", + "Molesters", + "Monster Girls", + "Monster Sex", + "Monsters", + "Moral Degeneration", + "Mother and Daughter", + "Mother and Son", + "Mother Complex", + "Mother-in-Law", + "Multiple Penetration", + "Necrophilia", + "Neighbors", + "Netorare", + "Netori", + "Newlyweds", + "Nipple Intercourse", + "Nipple Play", + "No Penetration", + "Non-Human Pregnancy", + "Nuns", + "Nurses", + "Obsessive Love", + "Office Ladies", + "Old Lady", + "Old Man", + "Older Brother", + "Older Female Young Boy", + "Older Female Younger Male", + "Older Male Younger Female", + "Older Sister", + "Oneshot", + "Outdoor Intercourse", + "Paizuri", + "Pantyhose", + "Partial Censorship", + "Partially Colored", + "Pegging", + "Personality Change", + "Perverted Boss", + "Perverted Characters", + "Perverted Teachers", + "Perverts", + "Petplay", + "Pets", + "Piercings", + "Plain Girls", + "Plastic Surgery", + "Polygamy", + "Poor Characters", + "Poor Grammar", + "Porn Industry", + "Porn Stars", + "Porn with Plot", + "Possessed", + "Possession", + "Possessive Lover", + "Pregnancy", + "Pretend Rape", + "Priestesses", + "Priests", + "Princes", + "Princesses", + "Prisoners", + "Proactive Protagonist", + "Producers", + "Prostitution", + "Public Intercourse", + "Public Nudity", + "Punishment", + "Punishment Sex", + "Queens", + "Rabbit Girls", + "Rape", + "Reverse Harem", + "Reverse Rape", + "Rewrite", + "Rich Boy", + "Rich Family", + "Rich Girl", + "Robotics", + "Romance", + "Rough Grammar", + "Rushed Ending/Axed", + "Sadist", + "Sadomasochism", + "Scat", + "School Girls", + "School Intercourse", + "School Life", + "School Nurse-Student Relationship", + "Secret Crush", + "Secret Relationship", + "Seduction", + "Senpai-Kouhai Relationship", + "Serialized", + "Sex Addicts", + "Sex Friends", + "Sex Friends Become Lovers", + "Sex Industry", + "Sex Slaves", + "Sex Toys", + "Sexual Abuse", + "Sexual Assault", + "Sexual Frustration", + "Shemale", + "Shimoneta to Iu Gainen ga Sonzai Shinai Taikutsu na Sekai", + "Shota", + "Shy Characters", + "Sibling Love", + "Sister and Brother", + "Sister Complex", + "Sister-in-Law", + "Sisters", + "Sketchy Art Style", + "Skinsuit", + "Sleep Intercourse", + "Sluts", + "Small Breasts", + "Son Complex", + "Spirits", + "Squirting", + "Star-Crossed Lover/s", + "Step-Daughter", + "Step-Father", + "Step-Father/Step-Daughter Relationship", + "Step-Mother", + "Step-Mother/Step-Son Relationship", + "Step-Sibling Love", + "Step-Siblings", + "Step-Son", + "Stockings", + "Student Council", + "Student-Tutor Relationship", + "Succubus", + "Sudden Appearance", + "Sudden Confession", + "Swimsuit/s", + "Swinging", + "Tanned", + "Teacher-Student Relationship", + "Teacher-Teacher Relationship", + "Teachers", + "Tentacles", + "Threesome (MFF)", + "Threesome (MMF)", + "Threesome (Other)", + "Time Stop", + "Tomboy", + "Torture", + "Tragedy", + "Transgender", + "Trap", + "Tribadism", + "Tsundere", + "Tutors", + "Twincest", + "Twins", + "Un-censored", + "Uncle and Niece", + "Unlucky Character/s", + "Unrequited Love", + "Unusual Pupils", + "Urethral Intercourse", + "Urethral Play", + "Urination", + "Vampires", + "Virgins", + "Virtual Reality", + "Vore", + "Voyeurism", + "Waitresses", + "Webtoon", + "Werewolf", + "Widow", + "Wife Corruption", + "Wife Depravity", + "Wife-Wives", + "Witches", + "Wolf Girls", + "X-Ray", + "Yandere", + "Yaoi", + "Youkai", + "Young Master", + "Yuri", + "Yuri as a Subplot", + "Zombies" + ], + "ids": [ + "128", + "496", + "561", + "203", + "89", + "501", + "578", + "127", + "510", + "18", + "166", + "512", + "567", + "614", + "699", + "56", + "170", + "2", + "462", + "1177", + "322", + "785", + "178", + "150", + "209", + "248", + "1239", + "726", + "198", + "624", + "1069", + "43", + "589", + "711", + "1067", + "57", + "748", + "194", + "83", + "396", + "71", + "155", + "562", + "8", + "532", + "160", + "502", + "77", + "756", + "371", + "505", + "238", + "1421", + "659", + "105", + "1062", + "1649", + "41", + "80", + "444", + "1070", + "459", + "435", + "144", + "679", + "414", + "138", + "152", + "395", + "1607", + "577", + "242", + "51", + "381", + "1063", + "531", + "137", + "58", + "455", + "1650", + "734", + "145", + "35", + "36", + "1223", + "709", + "560", + "700", + "642", + "88", + "59", + "22", + "24", + "196", + "183", + "74", + "456", + "466", + "290", + "53", + "654", + "762", + "422", + "461", + "362", + "124", + "499", + "406", + "173", + "511", + "592", + "361", + "205", + "60", + "94", + "21", + "106", + "199", + "101", + "19", + "465", + "1191", + "643", + "188", + "31", + "84", + "716", + "464", + "573", + "576", + "278", + "803", + "42", + "286", + "647", + "474", + "171", + "95", + "303", + "1384", + "648", + "574", + "443", + "44", + "418", + "667", + "114", + "1194", + "1188", + "732", + "3", + "96", + "112", + "177", + "11", + "1620", + "1157", + "839", + "113", + "179", + "438", + "37", + "1064", + "1238", + "133", + "118", + "477", + "15", + "524", + "646", + "141", + "735", + "629", + "109", + "139", + "636", + "390", + "460", + "4", + "182", + "92", + "1065", + "1640", + "97", + "1527", + "103", + "45", + "86", + "427", + "1037", + "658", + "5", + "258", + "1066", + "28", + "46", + "556", + "669", + "1630", + "6", + "446", + "525", + "366", + "134", + "142", + "208", + "174", + "165", + "167", + "204", + "110", + "76", + "7", + "620", + "61", + "32", + "33", + "338", + "486", + "403", + "369", + "1646", + "185", + "78", + "52", + "621", + "713", + "1591", + "644", + "69", + "1122", + "108", + "622", + "233", + "1382", + "111", + "374", + "296", + "153", + "641", + "1639", + "1210", + "463", + "584", + "62", + "397", + "401", + "17", + "121", + "63", + "421", + "1182", + "169", + "640", + "146", + "75", + "29", + "65", + "536", + "1648", + "448", + "515", + "588", + "175", + "38", + "441", + "1161", + "503", + "66", + "129", + "587", + "172", + "568", + "34", + "543", + "590", + "357", + "364", + "365", + "157", + "570", + "537", + "79", + "123", + "85", + "493", + "187", + "498", + "115", + "9", + "143", + "72", + "1160", + "180", + "181", + "67", + "14", + "20", + "1651", + "122", + "168", + "119", + "98", + "262", + "39", + "26", + "117", + "526", + "81", + "549", + "104", + "16", + "30", + "158", + "82", + "107", + "420", + "197", + "176", + "116", + "572", + "1204", + "1012", + "23", + "162", + "164", + "482", + "102", + "1561", + "449", + "190", + "1606", + "148", + "469", + "99", + "207", + "472", + "1645", + "457", + "130", + "131", + "132", + "184", + "47", + "70", + "126", + "195", + "447", + "159", + "40", + "73", + "154", + "93", + "372", + "1571", + "521", + "348", + "68", + "473", + "10", + "604", + "605", + "48", + "1179", + "161", + "1068", + "27", + "125", + "54", + "140", + "163", + "151", + "147", + "49", + "25", + "804", + "87", + "428", + "1192", + "363", + "191", + "100", + "354", + "355", + "149", + "724", + "64", + "451", + "1476", + "202", + "135", + "156", + "136", + "471", + "523", + "489", + "310", + "201", + "12", + "285", + "120", + "13", + "1221", + "1549" + ] + } +] diff --git a/sources/en.hentai2read/res/icon.png b/sources/en.hentai2read/res/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c09af01b5748345a91dd55bb7f72f716e9579e7c GIT binary patch literal 11197 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 literal 0 HcmV?d00001 diff --git a/sources/en.hentai2read/res/source.json b/sources/en.hentai2read/res/source.json new file mode 100644 index 000000000..b5ec3e2f8 --- /dev/null +++ b/sources/en.hentai2read/res/source.json @@ -0,0 +1,42 @@ +{ + "info": { + "id": "en.hentai2read", + "name": "Hentai2Read", + "version": 1, + "url": "https://hentai2read.com", + "contentRating": 2, + "languages": [ + "en" + ], + "minAppVersion": "0.7.1" + }, + "listings": [ + { + "id": "latest", + "name": "Latest" + }, + { + "id": "newest", + "name": "Newest" + }, + { + "id": "trending", + "name": "Trending" + }, + { + "id": "last-week", + "name": "Top Week" + }, + { + "id": "last-month", + "name": "Top Month" + }, + { + "id": "staff-picks", + "name": "Staff Picks" + } + ], + "config": { + "supportsTagSearch": true + } +} diff --git a/sources/en.hentai2read/src/lib.rs b/sources/en.hentai2read/src/lib.rs new file mode 100644 index 000000000..9a2a69164 --- /dev/null +++ b/sources/en.hentai2read/src/lib.rs @@ -0,0 +1,337 @@ +#![no_std] +use aidoku::{ + Chapter, DeepLinkHandler, DeepLinkResult, FilterValue, Listing, ListingProvider, Manga, + MangaPageResult, Page, PageContent, Result, Source, + alloc::{String, Vec, string::ToString}, + helpers::uri::encode_uri_component, + imports::net::Request, + prelude::*, +}; +use core::cell::RefCell; +use serde::de::DeserializeOwned; + +mod models; +mod search; +use models::*; +use search::{NameSearch, SearchParams}; + +pub const BASE_URL: &str = "https://hentai2read.com"; + +/// Tapped tags are sent by name, so their ids are found in the static tag filter. +fn tag_id(name: &str) -> Option { + let filters: Vec = + serde_json::from_str(include_str!("../res/filters.json")).ok()?; + let tags = filters.into_iter().find(|filter| filter.id == "tags")?; + let index = tags + .options + .iter() + .position(|option| option.eq_ignore_ascii_case(name))?; + tags.ids.into_iter().nth(index) +} + +/// Sort filter options as (search value, browse path segment). +const SORTS: [(&str, &str); 6] = [ + ("", ""), // Relevance; browsing has no relevance, so it falls back to recently updated + ("latest", ""), + ("newest", "newest"), + ("most-viewed", "most-viewed"), + ("top-rated", "top-rated"), + ("alphabetical", "alphabetical"), +]; + +pub fn absolute_url(path: &str) -> String { + if path.starts_with("https://") || path.starts_with("http://") { + path.into() + } else { + format!("{BASE_URL}{path}") + } +} + +// Keys from the previous version of this source are old site slugs (`one_hurricane`) and chapter +// numbers (`8`). The site redirects those old urls to the current pages, so they still resolve. +fn manga_url(key: &str) -> String { + if key.starts_with('/') { + absolute_url(key) + } else { + format!("{BASE_URL}/{key}/") + } +} + +fn reader_url(manga_key: &str, chapter_key: &str) -> String { + if chapter_key.starts_with('/') { + format!("{BASE_URL}{chapter_key}/page-1") + } else { + format!("{BASE_URL}/{manga_key}/{chapter_key}/") + } +} + +/// The site is an Inertia.js app, so every page embeds its full data as JSON. +fn fetch_props(url: &str) -> Result { + let json = Request::get(url)? + .html()? + .select_first("script[data-page=app]") + .and_then(|script| script.data()) + .ok_or_else(|| error!("No page data found at {url}"))?; + serde_json::from_str::>(&json) + .map(|page| page.props) + .map_err(|err| error!("Unexpected page data at {url}: {err}")) +} + +fn exact_id(tags: &[TagRef], name: &str) -> Option { + tags.iter() + .find(|tag| tag.name.eq_ignore_ascii_case(name)) + .map(|tag| tag.id) +} + +/// Prefers an exact name match over the site's first (most popular) result. +fn best_id(tags: &[TagRef], name: &str) -> Option { + exact_id(tags, name).or(tags.first().map(|tag| tag.id)) +} + +/// Searches are by tag id, so artist names are looked up through the site's artist autocomplete. +fn search_artists(name: &str) -> Result> { + Request::get(format!( + "{BASE_URL}/search/artists?q={}", + encode_uri_component(name) + ))? + .header("Accept", "application/json") + .json_owned() +} + +/// Parodies have no autocomplete endpoint, so they are looked up through the parody tag index. +fn search_parodies(name: &str) -> Result> { + let props: TagIndexProps = fetch_props(&format!( + "{BASE_URL}/tags/parody?q={}", + encode_uri_component(name) + ))?; + Ok(props.tags.data) +} + +fn fetch_list(url: &str) -> Result { + Ok(fetch_props::(url)?.books.into()) +} + +/// Browse pages are paginated by path, e.g. `/browse/doujinshi/completed/most-viewed/page-2`. +fn browse_url(segments: &[&str], page: i32) -> String { + let mut url = format!("{BASE_URL}/browse"); + for segment in segments.iter().filter(|segment| !segment.is_empty()) { + url.push('/'); + url.push_str(segment); + } + format!("{url}/page-{page}") +} + +struct Hentai2Read { + /// The current name search, continued by the following pages. + name_search: RefCell>, +} + +impl Source for Hentai2Read { + fn new() -> Self { + Self { + name_search: RefCell::new(None), + } + } + + fn get_search_manga_list( + &self, + query: Option, + page: i32, + filters: Vec, + ) -> Result { + let mut kind = String::new(); + let mut status = String::new(); + let mut sort = 0; + let mut match_any = false; + let mut with = Vec::new(); + let mut without = Vec::new(); + let mut artist = None; + let mut tapped = None; + + for filter in filters { + match filter { + FilterValue::Text { id, value } if id == "author" => artist = Some(value), + FilterValue::Select { id, value } => match id.as_str() { + "type" => kind = value, + "status" => status = value, + // Sent when a tag is tapped on a series page. + "genre" => tapped = Some(value), + _ => {} + }, + FilterValue::Sort { index, .. } => sort = usize::try_from(index).unwrap_or(0), + FilterValue::Check { id, value } if id == "any" => match_any = value == 1, + FilterValue::MultiSelect { + id, + included, + excluded, + } if id == "tags" => { + with = included; + without = excluded; + } + _ => {} + } + } + + if let Some(name) = artist + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + { + match best_id(&search_artists(name)?, name) { + Some(id) => with.push(id.to_string()), + None => return Ok(MangaPageResult::default()), + } + // The artist is sent as a tag, so it would become optional when matching any tag. + match_any = false; + } + + let (search_sort, browse_sort) = SORTS.get(sort).copied().unwrap_or_default(); + let query = query.as_deref().map(str::trim).unwrap_or_default(); + + // Browsing paginates deeper than search, which is capped at 1000 results. + if query.is_empty() && tapped.is_none() && with.is_empty() && without.is_empty() { + return fetch_list(&browse_url(&[&kind, &status, browse_sort], page)); + } + + let params = SearchParams { + kind, + status, + sort: search_sort, + with, + without, + match_any, + }; + if query.is_empty() && tapped.is_none() { + return fetch_list(¶ms.url("", None, page)); + } + + let key = NameSearch::key(¶ms, query, tapped.as_deref()); + let mut name_search = self.name_search.borrow_mut(); + if !name_search + .as_ref() + .is_some_and(|search| search.is_continued_by(&key, page)) + { + *name_search = Some(NameSearch::new(key, params, query, tapped.as_deref())?); + } + match name_search.as_mut() { + Some(search) => search.next(), + None => Ok(MangaPageResult::default()), + } + } + + fn get_manga_update( + &self, + mut manga: Manga, + needs_details: bool, + needs_chapters: bool, + ) -> Result { + if !needs_details && !needs_chapters { + return Ok(manga); + } + + let BookProps { + book, + chapters, + stats, + } = fetch_props(&manga_url(&manga.key))?; + + if needs_chapters { + manga.chapters = Some( + chapters + .into_iter() + .map(|chapter| chapter.into_chapter(&book.title)) + .collect(), + ); + } + if needs_details { + book.apply_to(stats, &mut manga); + } + + Ok(manga) + } + + fn get_page_list(&self, manga: Manga, chapter: Chapter) -> Result> { + let props: ReaderProps = fetch_props(&reader_url(&manga.key, &chapter.key))?; + Ok(props + .images + .iter() + .map(|path| Page { + content: PageContent::url(absolute_url(path)), + ..Default::default() + }) + .collect()) + } +} + +impl ListingProvider for Hentai2Read { + fn get_manga_list(&self, listing: Listing, page: i32) -> Result { + // Tag pages opened from description links, e.g. `/parody/751-kantai-collection`. + if listing.id.starts_with('/') { + return fetch_list(&format!("{BASE_URL}{}/page-{page}", listing.id)); + } + let segment = match listing.id.as_str() { + "latest" => "", + id => id, + }; + fetch_list(&browse_url(&[segment], page)) + } +} + +/// Turns a tag slug like `751-kantai-collection` into a title like `Kantai Collection`. +fn slug_title(slug: &str) -> String { + let name = slug.split_once('-').map_or(slug, |(_, name)| name); + let mut title = String::with_capacity(name.len()); + for (index, word) in name.split('-').enumerate() { + if index > 0 { + title.push(' '); + } + let mut chars = word.chars(); + if let Some(first) = chars.next() { + title.extend(first.to_uppercase()); + title.push_str(chars.as_str()); + } + } + title +} + +impl DeepLinkHandler for Hentai2Read { + fn handle_deep_link(&self, url: String) -> Result> { + let Some(path) = url.strip_prefix(BASE_URL) else { + return Ok(None); + }; + let path = path.split(['?', '#']).next().unwrap_or_default(); + let mut segments = path.split('/').filter(|segment| !segment.is_empty()); + + match (segments.next(), segments.next(), segments.next()) { + (Some("reader"), Some(slug), Some(chapter_id)) => { + // Reader urls don't include the series type, so the series url is read from the page. + let key = format!("/reader/{slug}/{chapter_id}"); + let props: ReaderProps = fetch_props(&format!("{BASE_URL}{key}/page-1"))?; + Ok(Some(DeepLinkResult::Chapter { + manga_key: props.book.url, + key, + })) + } + (Some(kind @ ("original" | "doujinshi" | "imageset")), Some(slug), None) => { + Ok(Some(DeepLinkResult::Manga { + key: format!("/{kind}/{slug}"), + })) + } + ( + Some( + kind @ ("artist" | "parody" | "character" | "publisher" | "content" + | "category"), + ), + Some(slug), + None, + ) => Ok(Some(DeepLinkResult::Listing(Listing { + id: format!("/{kind}/{slug}"), + name: slug_title(slug), + ..Default::default() + }))), + _ => Ok(None), + } + } +} + +register_source!(Hentai2Read, ListingProvider, DeepLinkHandler); diff --git a/sources/en.hentai2read/src/models.rs b/sources/en.hentai2read/src/models.rs new file mode 100644 index 000000000..fd23469eb --- /dev/null +++ b/sources/en.hentai2read/src/models.rs @@ -0,0 +1,305 @@ +use crate::{BASE_URL, absolute_url}; +use aidoku::{ + Chapter, ContentRating, Manga, MangaPageResult, MangaStatus, UpdateStrategy, Viewer, + alloc::{String, Vec, string::ToString}, + imports::std::parse_date_with_options, + prelude::*, +}; +use serde::Deserialize; + +/// The Inertia.js page object embedded in every page as `', re.S) + + +def fetch(url: str, accept: str) -> str: + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": accept}) + with urllib.request.urlopen(request, timeout=30) as response: + return response.read().decode("utf-8") + + +def content_tags() -> list[tuple[int, str]]: + tags = json.loads(fetch(f"{BASE_URL}/search/tags", "application/json")) + return [(tag["id"], tag["name"]) for tag in tags] + + +def categories() -> list[tuple[int, str]]: + found = [] + page, last_page = 1, 1 + while page <= last_page: + html = fetch(f"{BASE_URL}/tags/category?page={page}", "text/html") + match = PAGE_DATA.search(html) + if not match: + sys.exit(f"no page data at /tags/category?page={page} (blocked by Cloudflare?)") + tags = json.loads(match.group(1))["props"]["tags"] + found += [(tag["id"], tag["name"]) for tag in tags["data"]] + last_page = tags.get("lastPage", 1) + page += 1 + return found + + +def main() -> None: + parser = argparse.ArgumentParser(description="Regenerate the tags filter from Hentai2Read.") + parser.add_argument("--check", action="store_true", help="exit 1 if filters.json is out of date") + args = parser.parse_args() + + tags = {tag_id: name for tag_id, name in content_tags() + categories()} + names = list(tags.values()) + duplicates = {name for name in names if names.count(name) > 1} + if duplicates: + sys.exit(f"duplicate tag names would be ambiguous in the filter: {sorted(duplicates)}") + ordered = sorted(tags.items(), key=lambda tag: tag[1].lower()) + + filters = json.loads(FILTERS_PATH.read_text()) + tag_filter = next(filter for filter in filters if filter.get("id") == "tags") + old = dict(zip(tag_filter["ids"], tag_filter["options"])) + tag_filter["options"] = [name for _, name in ordered] + tag_filter["ids"] = [str(tag_id) for tag_id, _ in ordered] + new = dict(zip(tag_filter["ids"], tag_filter["options"])) + + added = sorted(new.keys() - old.keys(), key=int) + removed = sorted(old.keys() - new.keys(), key=int) + renamed = sorted((key for key in new.keys() & old.keys() if new[key] != old[key]), key=int) + for key in added: + print(f"+ {key} {new[key]}") + for key in removed: + print(f"- {key} {old[key]}") + for key in renamed: + print(f"~ {key} {old[key]} -> {new[key]}") + print(f"{len(ordered)} tags: {len(added)} added, {len(removed)} removed, {len(renamed)} renamed") + + output = json.dumps(filters, indent="\t", ensure_ascii=False) + "\n" + if args.check: + sys.exit(0 if output == FILTERS_PATH.read_text() else 1) + FILTERS_PATH.write_text(output) + + +if __name__ == "__main__": + main() diff --git a/sources/en.hentai2read/src/lib.rs b/sources/en.hentai2read/src/lib.rs index 9a2a69164..d10db9b45 100644 --- a/sources/en.hentai2read/src/lib.rs +++ b/sources/en.hentai2read/src/lib.rs @@ -13,21 +13,19 @@ use serde::de::DeserializeOwned; mod models; mod search; use models::*; -use search::{NameSearch, SearchParams}; +use search::{NameIds, NameSearch, SearchParams}; pub const BASE_URL: &str = "https://hentai2read.com"; -/// Tapped tags are sent by name, so their ids are found in the static tag filter. -fn tag_id(name: &str) -> Option { - let filters: Vec = - serde_json::from_str(include_str!("../res/filters.json")).ok()?; - let tags = filters.into_iter().find(|filter| filter.id == "tags")?; - let index = tags - .options - .iter() - .position(|option| option.eq_ignore_ascii_case(name))?; - tags.ids.into_iter().nth(index) -} +/// Tag page kinds, e.g. `/parody/751-kantai-collection`, that open as listings. +pub const TAG_KINDS: [&str; 6] = [ + "artist", + "parody", + "character", + "publisher", + "content", + "category", +]; /// Sort filter options as (search value, browse path segment). const SORTS: [(&str, &str); 6] = [ @@ -77,34 +75,26 @@ fn fetch_props(url: &str) -> Result { .map_err(|err| error!("Unexpected page data at {url}: {err}")) } -fn exact_id(tags: &[TagRef], name: &str) -> Option { - tags.iter() - .find(|tag| tag.name.eq_ignore_ascii_case(name)) - .map(|tag| tag.id) -} - -/// Prefers an exact name match over the site's first (most popular) result. -fn best_id(tags: &[TagRef], name: &str) -> Option { - exact_id(tags, name).or(tags.first().map(|tag| tag.id)) -} - -/// Searches are by tag id, so artist names are looked up through the site's artist autocomplete. -fn search_artists(name: &str) -> Result> { - Request::get(format!( +/// Searches are by tag id, so artist names typed in the artist filter are looked up through the +/// site's artist autocomplete, preferring an exact name over the first (most popular) result. +/// Results are remembered for the session. +fn find_artist_id(name: &str, artist_ids: &mut NameIds) -> Result> { + if let Some(ids) = artist_ids.get(name) { + return Ok(ids.first().cloned()); + } + let artists: Vec = Request::get(format!( "{BASE_URL}/search/artists?q={}", encode_uri_component(name) ))? .header("Accept", "application/json") - .json_owned() -} - -/// Parodies have no autocomplete endpoint, so they are looked up through the parody tag index. -fn search_parodies(name: &str) -> Result> { - let props: TagIndexProps = fetch_props(&format!( - "{BASE_URL}/tags/parody?q={}", - encode_uri_component(name) - ))?; - Ok(props.tags.data) + .json_owned()?; + let id = artists + .iter() + .find(|artist| artist.name.eq_ignore_ascii_case(name)) + .or(artists.first()) + .map(|artist| artist.id.to_string()); + artist_ids.insert(name, id.iter().cloned().collect()); + Ok(id) } fn fetch_list(url: &str) -> Result { @@ -124,12 +114,18 @@ fn browse_url(segments: &[&str], page: i32) -> String { struct Hentai2Read { /// The current name search, continued by the following pages. name_search: RefCell>, + /// Ids of the tags, artists and parodies named exactly like earlier searches. + name_ids: RefCell, + /// Artist ids found for names typed in the artist filter. + artist_ids: RefCell, } impl Source for Hentai2Read { fn new() -> Self { Self { name_search: RefCell::new(None), + name_ids: RefCell::new(NameIds::default()), + artist_ids: RefCell::new(NameIds::default()), } } @@ -177,8 +173,8 @@ impl Source for Hentai2Read { .map(str::trim) .filter(|name| !name.is_empty()) { - match best_id(&search_artists(name)?, name) { - Some(id) => with.push(id.to_string()), + match find_artist_id(name, &mut self.artist_ids.borrow_mut())? { + Some(id) => with.push(id), None => return Ok(MangaPageResult::default()), } // The artist is sent as a tag, so it would become optional when matching any tag. @@ -211,7 +207,13 @@ impl Source for Hentai2Read { .as_ref() .is_some_and(|search| search.is_continued_by(&key, page)) { - *name_search = Some(NameSearch::new(key, params, query, tapped.as_deref())?); + *name_search = Some(NameSearch::new( + key, + params, + query, + tapped.as_deref(), + &mut self.name_ids.borrow_mut(), + )?); } match name_search.as_mut() { Some(search) => search.next(), @@ -317,18 +319,13 @@ impl DeepLinkHandler for Hentai2Read { key: format!("/{kind}/{slug}"), })) } - ( - Some( - kind @ ("artist" | "parody" | "character" | "publisher" | "content" - | "category"), - ), - Some(slug), - None, - ) => Ok(Some(DeepLinkResult::Listing(Listing { - id: format!("/{kind}/{slug}"), - name: slug_title(slug), - ..Default::default() - }))), + (Some(kind), Some(slug), None) if TAG_KINDS.contains(&kind) => { + Ok(Some(DeepLinkResult::Listing(Listing { + id: format!("/{kind}/{slug}"), + name: slug_title(slug), + ..Default::default() + }))) + } _ => Ok(None), } } diff --git a/sources/en.hentai2read/src/models.rs b/sources/en.hentai2read/src/models.rs index fd23469eb..69b2714dc 100644 --- a/sources/en.hentai2read/src/models.rs +++ b/sources/en.hentai2read/src/models.rs @@ -1,4 +1,4 @@ -use crate::{BASE_URL, absolute_url}; +use crate::{BASE_URL, TAG_KINDS, absolute_url}; use aidoku::{ Chapter, ContentRating, Manga, MangaPageResult, MangaStatus, UpdateStrategy, Viewer, alloc::{String, Vec, string::ToString}, @@ -188,7 +188,15 @@ impl Book { let links: Vec = group .tags .iter() - .map(|tag| format!("[{}]({BASE_URL}{})", escape_markdown(&tag.name), tag.url)) + .map(|tag| { + let name = escape_markdown(&tag.name); + // Urls come from site data, so only tag page paths are linked. + if is_tag_page(&tag.url) { + format!("[{name}]({BASE_URL}{})", tag.url) + } else { + name + } + }) .collect(); info.push(format!("{}: {}", group.label, links.join(", "))); @@ -277,29 +285,27 @@ pub struct ReaderBook { pub url: String, } -/// A tag returned by the artist autocomplete or the tag index. +/// A tag, artist, parody or character, from the artist autocomplete or search results. #[derive(Deserialize)] pub struct TagRef { pub id: i32, pub name: String, } -#[derive(Deserialize)] -pub struct TagIndexProps { - pub tags: TagIndexPage, -} - -#[derive(Deserialize)] -pub struct TagIndexPage { - pub data: Vec, -} - -/// An entry of `res/filters.json`. -#[derive(Deserialize)] -pub struct StaticFilter { - pub id: String, - #[serde(default)] - pub options: Vec, - #[serde(default)] - pub ids: Vec, +/// Checks that a url is a tag page path like `/parody/751-kantai-collection`: a known tag kind, +/// a numeric id and a slug of letters, digits and dashes. +pub fn is_tag_page(url: &str) -> bool { + let Some((kind, slug)) = url.strip_prefix('/').and_then(|path| path.split_once('/')) else { + return false; + }; + let Some((id, name)) = slug.split_once('-') else { + return false; + }; + TAG_KINDS.contains(&kind) + && !id.is_empty() + && id.bytes().all(|byte| byte.is_ascii_digit()) + && !name.is_empty() + && name + .chars() + .all(|char| char.is_alphanumeric() || char == '-') } diff --git a/sources/en.hentai2read/src/search.rs b/sources/en.hentai2read/src/search.rs index cc8317125..abd67b1c5 100644 --- a/sources/en.hentai2read/src/search.rs +++ b/sources/en.hentai2read/src/search.rs @@ -1,8 +1,12 @@ -use crate::{BASE_URL, exact_id, fetch_props, models::*, search_artists, search_parodies, tag_id}; +use crate::{BASE_URL, fetch_props, models::*}; use aidoku::{ MangaPageResult, Result, - alloc::{String, Vec, collections::BTreeSet, string::ToString}, - helpers::uri::QueryParameters, + alloc::{ + String, Vec, + collections::{BTreeMap, BTreeSet}, + string::ToString, + }, + helpers::uri::{QueryParameters, encode_uri_component}, prelude::*, }; @@ -54,6 +58,37 @@ impl SearchParams { } } +/// Tag, artist and parody ids that exactly match a name, remembered for the session so repeated +/// searches for the same name don't look it up again. +#[derive(Default)] +pub struct NameIds(BTreeMap>); + +impl NameIds { + /// The cache is cleared when it grows past this many names. + const MAX_NAMES: usize = 256; + + pub fn get(&self, name: &str) -> Option<&Vec> { + self.0.get(&name.to_ascii_lowercase()) + } + + pub fn insert(&mut self, name: &str, ids: Vec) { + if self.0.len() >= Self::MAX_NAMES { + self.0.clear(); + } + self.0.insert(name.to_ascii_lowercase(), ids); + } +} + +/// Search results list every tag, artist, parody and character matching the query, so a single +/// search finds all the meanings of a name. +fn exact_ids(matching: &[TagRef], name: &str) -> Vec { + matching + .iter() + .filter(|tag| tag.name.eq_ignore_ascii_case(name)) + .map(|tag| tag.id.to_string()) + .collect() +} + /// One reading of a search: title matches, or the books of a tag, artist or parody. struct Part { query: String, @@ -95,36 +130,40 @@ impl NameSearch { params: SearchParams, query: &str, tapped: Option<&str>, + name_ids: &mut NameIds, ) -> Result { let mut parts = Vec::new(); - let mut ids: Vec = Vec::new(); - match tapped { - Some(name) => { - ids.extend(tag_id(name)); - ids.extend(exact_id(&search_artists(name)?, name).map(|id| id.to_string())); - ids.extend(exact_id(&search_parodies(name)?, name).map(|id| id.to_string())); - } + let ids = match tapped { + Some(name) => match name_ids.get(name) { + Some(ids) => ids.clone(), + None => { + let props: ListProps = fetch_props(&format!( + "{BASE_URL}/search?q={}", + encode_uri_component(name) + ))?; + let ids = exact_ids(&props.matching_tags, name); + name_ids.insert(name, ids.clone()); + ids + } + }, None => { - // Title search results list the tags, artists and parodies matching the query. + // The title search is needed for its results anyway, and also names the tags. let props: ListProps = fetch_props(¶ms.url(query, None, 1))?; - for tag in props.matching_tags { - if tag.name.eq_ignore_ascii_case(query) { - ids.push(tag.id.to_string()); - } - } + let ids = exact_ids(&props.matching_tags, query); + name_ids.insert(query, ids.clone()); parts.push(Part { query: query.into(), tag: None, total: props.books.total, first_page: Some(props.books), }); + ids } - } + }; // The query is the name itself unless a tag was tapped alongside a typed query. let tag_query = if tapped.is_some() { query } else { "" }; - ids.dedup(); for id in ids { let books = fetch_props::(¶ms.url(tag_query, Some(&id), 1))?.books; parts.push(Part { From 6287f8ff7e168f71af4dc083cf58483f2133cf33 Mon Sep 17 00:00:00 2001 From: "Josh L." Date: Mon, 19 Oct 2026 19:55:05 -0400 Subject: [PATCH 3/5] fix(en.hentai2read): resolve comments --- sources/en.hentai2read/res/source.json | 3 +- sources/en.hentai2read/src/helpers.rs | 100 ++++++++++++++++ sources/en.hentai2read/src/lib.rs | 155 ++++++------------------- 3 files changed, 136 insertions(+), 122 deletions(-) create mode 100644 sources/en.hentai2read/src/helpers.rs diff --git a/sources/en.hentai2read/res/source.json b/sources/en.hentai2read/res/source.json index b5ec3e2f8..208b91feb 100644 --- a/sources/en.hentai2read/res/source.json +++ b/sources/en.hentai2read/res/source.json @@ -2,7 +2,7 @@ "info": { "id": "en.hentai2read", "name": "Hentai2Read", - "version": 1, + "version": 3, "url": "https://hentai2read.com", "contentRating": 2, "languages": [ @@ -37,6 +37,7 @@ } ], "config": { + "breakingChangeVersion": 3, "supportsTagSearch": true } } diff --git a/sources/en.hentai2read/src/helpers.rs b/sources/en.hentai2read/src/helpers.rs new file mode 100644 index 000000000..03780c02c --- /dev/null +++ b/sources/en.hentai2read/src/helpers.rs @@ -0,0 +1,100 @@ +use crate::{BASE_URL, models::*, search::NameIds}; +use aidoku::{ + MangaPageResult, Result, + alloc::{String, Vec, string::ToString}, + helpers::uri::encode_uri_component, + imports::net::Request, + prelude::*, +}; +use serde::de::DeserializeOwned; + +pub fn absolute_url(path: &str) -> String { + if path.starts_with("https://") || path.starts_with("http://") { + path.into() + } else { + format!("{BASE_URL}{path}") + } +} + +// Keys from the previous version of this source are old site slugs (`one_hurricane`) and chapter +// numbers (`8`). The site redirects those old urls to the current pages, so they still resolve. +pub(super) fn manga_url(key: &str) -> String { + if key.starts_with('/') { + absolute_url(key) + } else { + format!("{BASE_URL}/{key}/") + } +} + +pub(super) fn reader_url(manga_key: &str, chapter_key: &str) -> String { + if chapter_key.starts_with('/') { + format!("{BASE_URL}{chapter_key}/page-1") + } else { + format!("{BASE_URL}/{manga_key}/{chapter_key}/") + } +} + +/// The site is an Inertia.js app, so every page embeds its full data as JSON. +pub(crate) fn fetch_props(url: &str) -> Result { + let json = Request::get(url)? + .html()? + .select_first("script[data-page=app]") + .and_then(|script| script.data()) + .ok_or_else(|| error!("No page data found at {url}"))?; + serde_json::from_str::>(&json) + .map(|page| page.props) + .map_err(|err| error!("Unexpected page data at {url}: {err}")) +} + +/// Searches are by tag id, so artist names typed in the artist filter are looked up through the +/// site's artist autocomplete, preferring an exact name over the first (most popular) result. +/// Results are remembered for the session. +pub(super) fn find_artist_id(name: &str, artist_ids: &mut NameIds) -> Result> { + if let Some(ids) = artist_ids.get(name) { + return Ok(ids.first().cloned()); + } + let artists: Vec = Request::get(format!( + "{BASE_URL}/search/artists?q={}", + encode_uri_component(name) + ))? + .header("Accept", "application/json") + .json_owned()?; + let id = artists + .iter() + .find(|artist| artist.name.eq_ignore_ascii_case(name)) + .or(artists.first()) + .map(|artist| artist.id.to_string()); + artist_ids.insert(name, id.iter().cloned().collect()); + Ok(id) +} + +pub(super) fn fetch_list(url: &str) -> Result { + Ok(fetch_props::(url)?.books.into()) +} + +/// Browse pages are paginated by path, e.g. `/browse/doujinshi/completed/most-viewed/page-2`. +pub(super) fn browse_url(segments: &[&str], page: i32) -> String { + let mut url = format!("{BASE_URL}/browse"); + for segment in segments.iter().filter(|segment| !segment.is_empty()) { + url.push('/'); + url.push_str(segment); + } + format!("{url}/page-{page}") +} + +/// Turns a tag slug like `751-kantai-collection` into a title like `Kantai Collection`. +pub(super) fn slug_title(slug: &str) -> String { + let name = slug.split_once('-').map_or(slug, |(_, name)| name); + let mut title = String::with_capacity(name.len()); + for (index, word) in name.split('-').enumerate() { + if index > 0 { + title.push(' '); + } + let mut chars = word.chars(); + if let Some(first) = chars.next() { + title.extend(first.to_uppercase()); + title.push_str(chars.as_str()); + } + } + title +} diff --git a/sources/en.hentai2read/src/lib.rs b/sources/en.hentai2read/src/lib.rs index d10db9b45..1b06ded2e 100644 --- a/sources/en.hentai2read/src/lib.rs +++ b/sources/en.hentai2read/src/lib.rs @@ -2,16 +2,17 @@ use aidoku::{ Chapter, DeepLinkHandler, DeepLinkResult, FilterValue, Listing, ListingProvider, Manga, MangaPageResult, Page, PageContent, Result, Source, - alloc::{String, Vec, string::ToString}, - helpers::uri::encode_uri_component, - imports::net::Request, + alloc::{String, Vec}, prelude::*, }; use core::cell::RefCell; -use serde::de::DeserializeOwned; +mod helpers; mod models; mod search; +pub use helpers::absolute_url; +pub(crate) use helpers::fetch_props; +use helpers::{browse_url, fetch_list, find_artist_id, manga_url, reader_url, slug_title}; use models::*; use search::{NameIds, NameSearch, SearchParams}; @@ -37,80 +38,6 @@ const SORTS: [(&str, &str); 6] = [ ("alphabetical", "alphabetical"), ]; -pub fn absolute_url(path: &str) -> String { - if path.starts_with("https://") || path.starts_with("http://") { - path.into() - } else { - format!("{BASE_URL}{path}") - } -} - -// Keys from the previous version of this source are old site slugs (`one_hurricane`) and chapter -// numbers (`8`). The site redirects those old urls to the current pages, so they still resolve. -fn manga_url(key: &str) -> String { - if key.starts_with('/') { - absolute_url(key) - } else { - format!("{BASE_URL}/{key}/") - } -} - -fn reader_url(manga_key: &str, chapter_key: &str) -> String { - if chapter_key.starts_with('/') { - format!("{BASE_URL}{chapter_key}/page-1") - } else { - format!("{BASE_URL}/{manga_key}/{chapter_key}/") - } -} - -/// The site is an Inertia.js app, so every page embeds its full data as JSON. -fn fetch_props(url: &str) -> Result { - let json = Request::get(url)? - .html()? - .select_first("script[data-page=app]") - .and_then(|script| script.data()) - .ok_or_else(|| error!("No page data found at {url}"))?; - serde_json::from_str::>(&json) - .map(|page| page.props) - .map_err(|err| error!("Unexpected page data at {url}: {err}")) -} - -/// Searches are by tag id, so artist names typed in the artist filter are looked up through the -/// site's artist autocomplete, preferring an exact name over the first (most popular) result. -/// Results are remembered for the session. -fn find_artist_id(name: &str, artist_ids: &mut NameIds) -> Result> { - if let Some(ids) = artist_ids.get(name) { - return Ok(ids.first().cloned()); - } - let artists: Vec = Request::get(format!( - "{BASE_URL}/search/artists?q={}", - encode_uri_component(name) - ))? - .header("Accept", "application/json") - .json_owned()?; - let id = artists - .iter() - .find(|artist| artist.name.eq_ignore_ascii_case(name)) - .or(artists.first()) - .map(|artist| artist.id.to_string()); - artist_ids.insert(name, id.iter().cloned().collect()); - Ok(id) -} - -fn fetch_list(url: &str) -> Result { - Ok(fetch_props::(url)?.books.into()) -} - -/// Browse pages are paginated by path, e.g. `/browse/doujinshi/completed/most-viewed/page-2`. -fn browse_url(segments: &[&str], page: i32) -> String { - let mut url = format!("{BASE_URL}/browse"); - for segment in segments.iter().filter(|segment| !segment.is_empty()) { - url.push('/'); - url.push_str(segment); - } - format!("{url}/page-{page}") -} - struct Hentai2Read { /// The current name search, continued by the following pages. name_search: RefCell>, @@ -135,12 +62,15 @@ impl Source for Hentai2Read { page: i32, filters: Vec, ) -> Result { - let mut kind = String::new(); - let mut status = String::new(); + let mut params = SearchParams { + kind: String::new(), + status: String::new(), + sort: "", + with: Vec::new(), + without: Vec::new(), + match_any: false, + }; let mut sort = 0; - let mut match_any = false; - let mut with = Vec::new(); - let mut without = Vec::new(); let mut artist = None; let mut tapped = None; @@ -148,21 +78,21 @@ impl Source for Hentai2Read { match filter { FilterValue::Text { id, value } if id == "author" => artist = Some(value), FilterValue::Select { id, value } => match id.as_str() { - "type" => kind = value, - "status" => status = value, + "type" => params.kind = value, + "status" => params.status = value, // Sent when a tag is tapped on a series page. "genre" => tapped = Some(value), _ => {} }, FilterValue::Sort { index, .. } => sort = usize::try_from(index).unwrap_or(0), - FilterValue::Check { id, value } if id == "any" => match_any = value == 1, + FilterValue::Check { id, value } if id == "any" => params.match_any = value == 1, FilterValue::MultiSelect { id, included, excluded, } if id == "tags" => { - with = included; - without = excluded; + params.with = included; + params.without = excluded; } _ => {} } @@ -174,29 +104,29 @@ impl Source for Hentai2Read { .filter(|name| !name.is_empty()) { match find_artist_id(name, &mut self.artist_ids.borrow_mut())? { - Some(id) => with.push(id), + Some(id) => params.with.push(id), None => return Ok(MangaPageResult::default()), } // The artist is sent as a tag, so it would become optional when matching any tag. - match_any = false; + params.match_any = false; } let (search_sort, browse_sort) = SORTS.get(sort).copied().unwrap_or_default(); + params.sort = search_sort; let query = query.as_deref().map(str::trim).unwrap_or_default(); // Browsing paginates deeper than search, which is capped at 1000 results. - if query.is_empty() && tapped.is_none() && with.is_empty() && without.is_empty() { - return fetch_list(&browse_url(&[&kind, &status, browse_sort], page)); + if query.is_empty() + && tapped.is_none() + && params.with.is_empty() + && params.without.is_empty() + { + return fetch_list(&browse_url( + &[¶ms.kind, ¶ms.status, browse_sort], + page, + )); } - let params = SearchParams { - kind, - status, - sort: search_sort, - with, - without, - match_any, - }; if query.is_empty() && tapped.is_none() { return fetch_list(¶ms.url("", None, page)); } @@ -215,10 +145,10 @@ impl Source for Hentai2Read { &mut self.name_ids.borrow_mut(), )?); } - match name_search.as_mut() { - Some(search) => search.next(), - None => Ok(MangaPageResult::default()), - } + name_search + .as_mut() + .expect("name search is initialized above") + .next() } fn get_manga_update( @@ -279,23 +209,6 @@ impl ListingProvider for Hentai2Read { } } -/// Turns a tag slug like `751-kantai-collection` into a title like `Kantai Collection`. -fn slug_title(slug: &str) -> String { - let name = slug.split_once('-').map_or(slug, |(_, name)| name); - let mut title = String::with_capacity(name.len()); - for (index, word) in name.split('-').enumerate() { - if index > 0 { - title.push(' '); - } - let mut chars = word.chars(); - if let Some(first) = chars.next() { - title.extend(first.to_uppercase()); - title.push_str(chars.as_str()); - } - } - title -} - impl DeepLinkHandler for Hentai2Read { fn handle_deep_link(&self, url: String) -> Result> { let Some(path) = url.strip_prefix(BASE_URL) else { From 965f6e3a5474e4f3d6088472f5299c0e973ed1e2 Mon Sep 17 00:00:00 2001 From: "Josh L." Date: Tue, 20 Oct 2026 18:42:52 -0400 Subject: [PATCH 4/5] address review feedback --- sources/en.hentai2read/src/lib.rs | 61 ++++++++++++++----------------- 1 file changed, 28 insertions(+), 33 deletions(-) diff --git a/sources/en.hentai2read/src/lib.rs b/sources/en.hentai2read/src/lib.rs index 1b06ded2e..a517f9c66 100644 --- a/sources/en.hentai2read/src/lib.rs +++ b/sources/en.hentai2read/src/lib.rs @@ -28,14 +28,15 @@ pub const TAG_KINDS: [&str; 6] = [ "category", ]; -/// Sort filter options as (search value, browse path segment). -const SORTS: [(&str, &str); 6] = [ - ("", ""), // Relevance; browsing has no relevance, so it falls back to recently updated - ("latest", ""), - ("newest", "newest"), - ("most-viewed", "most-viewed"), - ("top-rated", "top-rated"), - ("alphabetical", "alphabetical"), +/// Sort filter values, used as the search `sort` and the browse path segment. Browsing has no +/// relevance, so an empty sort falls back to recently updated. +const SORTS: [&str; 6] = [ + "", + "latest", + "newest", + "most-viewed", + "top-rated", + "alphabetical", ]; struct Hentai2Read { @@ -71,12 +72,20 @@ impl Source for Hentai2Read { match_any: false, }; let mut sort = 0; - let mut artist = None; + let mut artist_id = None; let mut tapped = None; for filter in filters { match filter { - FilterValue::Text { id, value } if id == "author" => artist = Some(value), + FilterValue::Text { id, value } if id == "author" => { + let name = value.trim(); + if !name.is_empty() { + match find_artist_id(name, &mut self.artist_ids.borrow_mut())? { + Some(id) => artist_id = Some(id), + None => return Ok(MangaPageResult::default()), + } + } + } FilterValue::Select { id, value } => match id.as_str() { "type" => params.kind = value, "status" => params.status = value, @@ -98,21 +107,13 @@ impl Source for Hentai2Read { } } - if let Some(name) = artist - .as_deref() - .map(str::trim) - .filter(|name| !name.is_empty()) - { - match find_artist_id(name, &mut self.artist_ids.borrow_mut())? { - Some(id) => params.with.push(id), - None => return Ok(MangaPageResult::default()), - } - // The artist is sent as a tag, so it would become optional when matching any tag. + // The artist is sent as a tag, so it would become optional when matching any tag. + if let Some(id) = artist_id { + params.with.push(id); params.match_any = false; } - let (search_sort, browse_sort) = SORTS.get(sort).copied().unwrap_or_default(); - params.sort = search_sort; + params.sort = SORTS.get(sort).copied().unwrap_or_default(); let query = query.as_deref().map(str::trim).unwrap_or_default(); // Browsing paginates deeper than search, which is capped at 1000 results. @@ -122,7 +123,7 @@ impl Source for Hentai2Read { && params.without.is_empty() { return fetch_list(&browse_url( - &[¶ms.kind, ¶ms.status, browse_sort], + &[¶ms.kind, ¶ms.status, params.sort], page, )); } @@ -157,10 +158,6 @@ impl Source for Hentai2Read { needs_details: bool, needs_chapters: bool, ) -> Result { - if !needs_details && !needs_chapters { - return Ok(manga); - } - let BookProps { book, chapters, @@ -197,15 +194,13 @@ impl Source for Hentai2Read { impl ListingProvider for Hentai2Read { fn get_manga_list(&self, listing: Listing, page: i32) -> Result { - // Tag pages opened from description links, e.g. `/parody/751-kantai-collection`. + // Tag pages from `handle_deep_link`, e.g. `/parody/751-kantai-collection`. Series + // descriptions link each artist, parody, character and publisher to its tag page, and + // tapping one of those links opens it through the deep link handler. if listing.id.starts_with('/') { return fetch_list(&format!("{BASE_URL}{}/page-{page}", listing.id)); } - let segment = match listing.id.as_str() { - "latest" => "", - id => id, - }; - fetch_list(&browse_url(&[segment], page)) + fetch_list(&browse_url(&[&listing.id], page)) } } From b7e8ab35f5527192af8c3e4b5180a6be33bafcc9 Mon Sep 17 00:00:00 2001 From: kantan <31490942+kkantan@users.noreply.github.com> Date: Fri, 23 Oct 2026 04:49:41 +0900 Subject: [PATCH 5/5] chore: hide check filter from header --- sources/en.hentai2read/res/filters.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/sources/en.hentai2read/res/filters.json b/sources/en.hentai2read/res/filters.json index bb3ece0d1..601c6ef4c 100644 --- a/sources/en.hentai2read/res/filters.json +++ b/sources/en.hentai2read/res/filters.json @@ -55,7 +55,8 @@ "type": "check", "id": "any", "title": "Tag matching", - "name": "Match any included tag" + "name": "Match any included tag", + "hideFromHeader": true }, { "type": "multi-select",