diff --git a/docs/environment-variables.md b/docs/environment-variables.md
index 8be2660c..651ce856 100644
--- a/docs/environment-variables.md
+++ b/docs/environment-variables.md
@@ -2750,6 +2750,7 @@ How long the bypass helper process may sit unused before it is shut down. Higher
| `LIBGEN_MIRROR_URLS` | Mirrors are tried in the order you add them until one works. | string (comma-separated) | _empty list_ |
| `ZLIB_MIRROR_URLS` | Only the first mirror in the list is used. | string (comma-separated) | _empty list_ |
| `WELIB_MIRROR_URLS` | Only the first mirror in the list is used. | string (comma-separated) | _empty list_ |
+| `OCEANOFPDF_MIRROR_URLS` | Searches and downloads directly, without Anna's Archive. Only the first mirror in the list is used. The site is behind Cloudflare, so the bypass must be enabled. | string (comma-separated) | _empty list_ |
Detailed descriptions
@@ -2800,4 +2801,13 @@ Only the first mirror in the list is used.
- **Type:** string (comma-separated)
- **Default:** _empty list_
+#### `OCEANOFPDF_MIRROR_URLS`
+
+**OceanofPDF**
+
+Searches and downloads directly, without Anna's Archive. Only the first mirror in the list is used. The site is behind Cloudflare, so the bypass must be enabled.
+
+- **Type:** string (comma-separated)
+- **Default:** _empty list_
+
diff --git a/shelfmark/bypass/challenge.py b/shelfmark/bypass/challenge.py
index ebca3e49..35948056 100644
--- a/shelfmark/bypass/challenge.py
+++ b/shelfmark/bypass/challenge.py
@@ -35,6 +35,13 @@ _RAW_HTML_MARKERS = (
"/.well-known/ddos-guard/",
)
+# Cloudflare's bot-detection beacon. Its script injects an iframe loading this path into
+# every page a Cloudflare site serves, so a browser-rendered page - what FlareSolverr and
+# the internal bypasser hand back - carries it after a *successful* solve. Real challenge
+# pages load from /cdn-cgi/challenge-platform/h/... and keep their other markers, so
+# discarding this one path before the scan does not hide them.
+_CLOUDFLARE_BEACON_PATH = "/cdn-cgi/challenge-platform/scripts/jsd/"
+
# An interstitial is a few KB of markup. Past that it is a real page that happens to
# mention a marker - a protected site links its own DDoS-Guard endpoints on every page.
MAX_CHALLENGE_HTML_CHARS = 64 * 1024
@@ -49,7 +56,7 @@ def challenge_marker(html: str) -> str | None:
"""
if not html or len(html) > MAX_CHALLENGE_HTML_CHARS:
return None
- lowered = html.lower()
+ lowered = html.lower().replace(_CLOUDFLARE_BEACON_PATH, "")
for marker in (
*_RAW_HTML_MARKERS,
*DDOS_GUARD_INDICATORS,
diff --git a/shelfmark/config/settings.py b/shelfmark/config/settings.py
index 71e0a545..ed6b559c 100644
--- a/shelfmark/config/settings.py
+++ b/shelfmark/config/settings.py
@@ -1592,6 +1592,13 @@ def _get_slow_source_options() -> list[dict[str, str | bool | None]]:
"isLocked": _get_reason("zlib") is not None,
"disabledReason": _get_reason("zlib"),
},
+ {
+ "id": "oceanofpdf",
+ "label": "OceanofPDF",
+ "description": "Searches and downloads on its own, so its position does not matter",
+ "isLocked": _get_reason("oceanofpdf") is not None,
+ "disabledReason": _get_reason("oceanofpdf"),
+ },
]
@@ -1604,6 +1611,7 @@ def _get_slow_source_defaults() -> list[dict[str, str | bool]]:
{"id": "aa-slow-wait", "enabled": True},
{"id": "welib", "enabled": _LEGACY_ALLOW_USE_WELIB},
{"id": "zlib", "enabled": True},
+ {"id": "oceanofpdf", "enabled": True},
]
@@ -1833,6 +1841,7 @@ def _on_save_mirrors(values: dict[str, Any]) -> dict[str, Any]:
"LIBGEN_MIRROR_URLS",
"ZLIB_MIRROR_URLS",
"WELIB_MIRROR_URLS",
+ "OCEANOFPDF_MIRROR_URLS",
}
for key in mirror_list_keys:
@@ -1918,6 +1927,17 @@ def mirror_settings() -> list[SettingsField]:
description="Only the first mirror in the list is used.",
placeholder="https://your-welib-mirror.example",
),
+ # === OCEANOFPDF ===
+ TagListField(
+ key="OCEANOFPDF_MIRROR_URLS",
+ label="OceanofPDF",
+ description=(
+ "Searches and downloads directly, without Anna's Archive. Only the first "
+ "mirror in the list is used. The site is behind Cloudflare, so the bypass "
+ "must be enabled."
+ ),
+ placeholder="https://oceanofpdf.com",
+ ),
]
diff --git a/shelfmark/core/mirrors.py b/shelfmark/core/mirrors.py
index 16ce5788..15fe783c 100644
--- a/shelfmark/core/mirrors.py
+++ b/shelfmark/core/mirrors.py
@@ -36,6 +36,7 @@ _DOWNLOAD_SOURCE_MIRROR_LABELS = {
"libgen": "LibGen",
"zlib": "Z-Library",
"welib": "Welib",
+ "oceanofpdf": "OceanofPDF",
}
@@ -198,6 +199,22 @@ def has_welib_mirror_configuration() -> bool:
return bool(get_welib_mirrors())
+def get_oceanofpdf_mirrors() -> list[str]:
+ """Get user-configured OceanofPDF mirrors, in the order they were entered."""
+ return _normalize_configured_urls(_get_config().get("OCEANOFPDF_MIRROR_URLS", None))
+
+
+def get_oceanofpdf_base_url() -> str | None:
+ """Get the OceanofPDF mirror in use. Only the first configured one is used."""
+ mirrors = get_oceanofpdf_mirrors()
+ return mirrors[0] if mirrors else None
+
+
+def has_oceanofpdf_mirror_configuration() -> bool:
+ """Return True when at least one OceanofPDF mirror URL is configured."""
+ return bool(get_oceanofpdf_mirrors())
+
+
def has_download_source_mirror_configuration(source_id: str) -> bool:
"""Return True when the requested direct-download source has mirror config."""
if source_id in {"aa-fast", "aa-slow", "aa-slow-nowait", "aa-slow-wait"}:
@@ -208,6 +225,8 @@ def has_download_source_mirror_configuration(source_id: str) -> bool:
return has_zlib_mirror_configuration()
if source_id == "welib":
return has_welib_mirror_configuration()
+ if source_id == "oceanofpdf":
+ return has_oceanofpdf_mirror_configuration()
return False
diff --git a/shelfmark/core/settings_registry.py b/shelfmark/core/settings_registry.py
index e2eb1248..2081d809 100644
--- a/shelfmark/core/settings_registry.py
+++ b/shelfmark/core/settings_registry.py
@@ -897,6 +897,7 @@ def _get_env_value_for_field(field: FieldBase) -> tuple[bool, object | None]:
"LIBGEN_MIRROR_URLS",
"ZLIB_MIRROR_URLS",
"WELIB_MIRROR_URLS",
+ "OCEANOFPDF_MIRROR_URLS",
} and isinstance(parsed, list):
parsed = _normalize_mirror_env_urls(parsed)
if field.key == "LOG_LEVEL" and isinstance(parsed, str):
diff --git a/shelfmark/release_sources/direct_download/annas_archive.py b/shelfmark/release_sources/direct_download/annas_archive.py
index 193797de..ffba209e 100644
--- a/shelfmark/release_sources/direct_download/annas_archive.py
+++ b/shelfmark/release_sources/direct_download/annas_archive.py
@@ -421,9 +421,10 @@ def _get_source_priority() -> list[SourcePriorityEntry]:
):
source["enabled"] = False
+ # OceanofPDF shares the list for its toggle but is its own provider, not an MD5 mirror.
slow_sources = _parse_source_priority_entries(
config.get("SOURCE_PRIORITY"),
- excluded_ids={"aa-fast", "libgen"},
+ excluded_ids={"aa-fast", "libgen", "oceanofpdf"},
)
for source in slow_sources:
if not mirrors.has_download_source_mirror_configuration(source["id"]):
diff --git a/shelfmark/release_sources/direct_download/oceanofpdf.py b/shelfmark/release_sources/direct_download/oceanofpdf.py
new file mode 100644
index 00000000..51cb99a5
--- /dev/null
+++ b/shelfmark/release_sources/direct_download/oceanofpdf.py
@@ -0,0 +1,480 @@
+"""OceanofPDF search and download.
+
+Unlike the Z-Library, LibGen and Welib sources, which only download files Anna's Archive
+found, OceanofPDF searches and downloads on its own. The whole site sits behind Cloudflare,
+so every page goes through `html_get_page` and its bypasser handoff.
+
+The download is three hops:
+
+1. The book page carries one POST form per format, targeting ``Fetching_Resource.php``
+ with hidden ``id`` (a file server) and ``filename`` fields.
+2. That endpoint answers with an interstitial page whose ````
+ points at a signed, expiring file URL on a separate file host.
+3. The file URL serves the book itself.
+"""
+
+import re
+from typing import TYPE_CHECKING
+from urllib.parse import quote_plus, urljoin, urlparse
+
+import requests
+from bs4 import BeautifulSoup, Tag
+
+from shelfmark.bypass.challenge import challenge_marker
+from shelfmark.core import search_deadline
+from shelfmark.core.config import config
+from shelfmark.core.logger import setup_logger
+from shelfmark.core.models import SearchFilters
+from shelfmark.download import http as downloader
+from shelfmark.download import network
+from shelfmark.release_sources.direct_download.common import (
+ MIN_VALID_FILE_SIZE,
+ DirectDownloadUnavailableError,
+ ParsedSearchResult,
+ get_attr,
+ html_response_text,
+ normalize_size,
+ parse_search_page,
+)
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+ from pathlib import Path
+ from threading import Event
+
+ from shelfmark.core.search_plan import ReleaseSearchPlan
+ from shelfmark.metadata_providers import BookMetadata
+ from shelfmark.release_sources import BrowseRecord
+
+logger = setup_logger(__name__)
+
+PROVIDER_ID = "oceanofpdf"
+
+_FORMAT_TAG_PATTERN = re.compile(r"\[([A-Za-z0-9]{2,5})\]")
+# Result summaries read "[PDF] [EPUB] Dune Download by Frank Herbert. Download Dune by...".
+# Anchored on the following "Download" because author names carry their own full stops
+# ("Anissa M. Bouziane").
+_AUTHOR_PATTERN = re.compile(r"\bDownload by (.+?)\.\s+Download\b")
+_META_REFRESH_PATTERN = re.compile(r"^\s*(\d+)?\s*;?\s*url\s*=\s*['\"]?([^'\"]+)", re.IGNORECASE)
+_FILE_SIZE_PATTERN = re.compile(
+ r"\b([A-Z0-9]{2,5}) File Size:\s*([\d.,]+\s*[KMGT]B)", re.IGNORECASE
+)
+_FETCH_FORM_MARKER = "fetching_resource"
+# Every page of the site's Genesis theme has this skip link; a challenge page does not.
+_SITE_PAGE_MARKER = "genesis-content"
+_HTML_SIGNATURES = (b" str | None:
+ from shelfmark.core import mirrors
+
+ return mirrors.get_oceanofpdf_base_url()
+
+
+def _is_switched_on() -> bool:
+ """Whether OceanofPDF's toggle in Download Sources -> Slow downloads is on.
+
+ A saved list with no OceanofPDF entry was saved before this provider existed. The
+ settings page shows a missing option as off, so it counts as off here too - otherwise
+ the page would show it off while searches still used it. With nothing saved at all,
+ the field's default applies, and that has it on.
+ """
+ entries = config.get("SOURCE_PRIORITY")
+ if not isinstance(entries, list):
+ return True
+ for entry in entries:
+ if isinstance(entry, dict) and entry.get("id") == PROVIDER_ID:
+ return bool(entry.get("enabled", True))
+ return False
+
+
+def _search_url(base_url: str, query: str) -> str:
+ return f"{base_url}/?s={quote_plus(query)}"
+
+
+def _image_src(article: Tag) -> str | None:
+ """Return the cover URL, skipping the lazy-load SVG placeholder in ``src``."""
+ for img in article.find_all("img"):
+ if not isinstance(img, Tag):
+ continue
+ for attr in ("data-src", "src"):
+ value = get_attr(img, attr)
+ if value and not value.startswith("data:"):
+ return value
+ return None
+
+
+def _extract_search_result(article: Tag) -> ParsedSearchResult | None:
+ """Extract one OceanofPDF search result for the shared parser."""
+ link = article.select_one("h2.entry-title a[href]")
+ if not isinstance(link, Tag):
+ return None
+ book_url = get_attr(link, "href")
+ title = link.get_text(" ", strip=True)
+ if not book_url or not title:
+ return None
+
+ label = get_attr(article, "aria-label") or ""
+ formats = tuple(dict.fromkeys(tag.lower() for tag in _FORMAT_TAG_PATTERN.findall(label)))
+
+ summary = article.select_one(".entry-content, .entry-summary")
+ summary_text = summary.get_text(" ", strip=True) if isinstance(summary, Tag) else ""
+ author_match = _AUTHOR_PATTERN.search(summary_text)
+
+ return ParsedSearchResult(
+ key=book_url,
+ title=title,
+ formats=formats,
+ author=author_match.group(1).strip() if author_match else None,
+ preview=_image_src(article),
+ content="book (unknown)",
+ source_url=book_url,
+ )
+
+
+def parse_search_results(html: str, filters: SearchFilters | None) -> list[BrowseRecord]:
+ """Parse an OceanofPDF search page into one record per result and format."""
+ return parse_search_page(
+ html,
+ filters,
+ provider_id=PROVIDER_ID,
+ item_selector="article",
+ extract_item=_extract_search_result,
+ )
+
+
+def _fetch_forms(soup: BeautifulSoup) -> list[Tag]:
+ return [
+ form
+ for form in soup.find_all("form")
+ if isinstance(form, Tag) and _FETCH_FORM_MARKER in (get_attr(form, "action") or "").lower()
+ ]
+
+
+def _form_fields(form: Tag) -> dict[str, str]:
+ fields: dict[str, str] = {}
+ for field in form.find_all("input"):
+ if not isinstance(field, Tag):
+ continue
+ name = get_attr(field, "name")
+ if name:
+ fields[name] = get_attr(field, "value") or ""
+ return fields
+
+
+def pick_download_form(
+ soup: BeautifulSoup, book_format: str | None
+) -> tuple[str, dict[str, str]] | None:
+ """Return the form action and fields that fetch ``book_format``.
+
+ Without a requested format the first form wins, which is the page's own order.
+ """
+ candidates = [
+ (get_attr(form, "action") or "", _form_fields(form)) for form in _fetch_forms(soup)
+ ]
+ if not candidates:
+ return None
+ if not book_format:
+ return candidates[0]
+ suffix = f".{book_format.lower()}"
+ for action, fields in candidates:
+ if fields.get("filename", "").lower().endswith(suffix):
+ return action, fields
+ return None
+
+
+def file_size_from_page(soup: BeautifulSoup, book_format: str | None) -> str | None:
+ """Read the "EPUB File Size: 952 KB" line for the requested format, if present."""
+ if not book_format:
+ return None
+ for label, size in _FILE_SIZE_PATTERN.findall(soup.get_text(" ", strip=True)):
+ if label.lower() == book_format.lower():
+ return normalize_size(size)
+ return None
+
+
+def parse_file_redirect(html: str, page_url: str) -> tuple[str, int] | None:
+ """Return the signed file URL and redirect delay from the interstitial page."""
+ soup = BeautifulSoup(html, "html.parser")
+ for meta in soup.find_all("meta"):
+ if not isinstance(meta, Tag):
+ continue
+ if (get_attr(meta, "http-equiv") or "").lower() != "refresh":
+ continue
+ match = _META_REFRESH_PATTERN.match(get_attr(meta, "content") or "")
+ if match:
+ return urljoin(page_url, match.group(2).strip()), int(match.group(1) or 0)
+
+ # Fallback for a page that drops the meta refresh for a plain link.
+ for anchor in soup.find_all("a", href=True):
+ href = get_attr(anchor, "href") or ""
+ if urlparse(href).path.lower().endswith((".epub", ".pdf")):
+ return urljoin(page_url, href), 0
+ return None
+
+
+def _post_fetch_form(action_url: str, fields: dict[str, str], referer: str) -> requests.Response:
+ """POST the download form, presenting any clearance the bypasser won for this host."""
+ hostname = urlparse(action_url).hostname or ""
+ # Not DOWNLOAD_HEADERS wholesale: its Accept-Encoding offers "br", Cloudflare answers
+ # in Brotli when offered, and requests cannot decode it without the brotli package -
+ # the page came back as compressed bytes with no link to find. Leaving Accept-Encoding
+ # to requests offers only what it can decode.
+ headers = {
+ "User-Agent": downloader.get_cf_user_agent_for_domain(hostname)
+ or downloader.DOWNLOAD_HEADERS["User-Agent"],
+ "Accept-Language": downloader.DOWNLOAD_HEADERS["Accept-Language"],
+ "Referer": referer,
+ "Origin": f"{urlparse(referer).scheme}://{urlparse(referer).netloc}",
+ }
+ return requests.post(
+ action_url,
+ data=fields,
+ headers=headers,
+ cookies=downloader.get_cf_cookies_for_domain(hostname),
+ timeout=_POST_TIMEOUT,
+ proxies=network.get_proxies(action_url),
+ verify=network.get_ssl_verify(action_url),
+ )
+
+
+def _log_unresolved_fetch_page(response: requests.Response) -> None:
+ """Record what the download form returned when no file link could be read off it.
+
+ The response body is not in the debug bundle, so without this a changed page, a
+ challenge and an undecoded body all look the same: "did not return a file link".
+ Diagnostics must never fail a download, so this swallows its own errors.
+ """
+ try:
+ text = response.text or ""
+ title_match = re.search(
+ r"
]*>(.*?)", text[:8000], re.IGNORECASE | re.DOTALL
+ )
+ logger.warning(
+ "OceanofPDF download page had no file link: status=%s url=%s bytes=%d "
+ "content-type=%r content-encoding=%r title=%r meta_refresh=%s challenge_marker=%r",
+ response.status_code,
+ response.url,
+ len(text),
+ response.headers.get("Content-Type"),
+ response.headers.get("Content-Encoding"),
+ " ".join(title_match.group(1).split())[:120] if title_match else None,
+ "http-equiv" in text.lower() and "refresh" in text.lower(),
+ challenge_marker(text),
+ )
+ except Exception:
+ logger.debug("Could not describe the OceanofPDF download page", exc_info=True)
+
+
+def _is_challenge(response: requests.Response) -> bool:
+ return response.status_code in (
+ _HTTP_FORBIDDEN,
+ _HTTP_SERVICE_UNAVAILABLE,
+ ) and bool(challenge_marker(response.text))
+
+
+class OceanofPDFProvider:
+ """OceanofPDF provider: searches the site and downloads from its file host."""
+
+ id = PROVIDER_ID
+ display_name = "OceanofPDF"
+
+ def is_enabled(self) -> bool:
+ # Every page is behind Cloudflare, so without the bypass a search can only fail.
+ return (
+ _base_url() is not None
+ and bool(config.get("USE_CF_BYPASS", True))
+ and _is_switched_on()
+ )
+
+ def handles(self, url: str) -> bool:
+ base_url = _base_url()
+ if not base_url:
+ return False
+ hostname = (urlparse(url).hostname or "").lower().rstrip(".")
+ return bool(hostname) and hostname == (urlparse(base_url).hostname or "").lower()
+
+ # --- Search ---
+
+ def _fetch_search_page(self, base_url: str, query: str) -> str:
+ url = _search_url(base_url, query)
+ selector = network.AAMirrorSelector()
+ response = downloader.html_get_page(url, selector=selector, allow_bypasser_fallback=True)
+ html = html_response_text(response)
+ if not html:
+ detail = getattr(selector, "last_failure", None) or "The site could not be reached."
+ msg = f"Unable to reach OceanofPDF. {detail}"
+ raise DirectDownloadUnavailableError(msg)
+ # Checked against the theme's own markup, not just the marker: a protected site's
+ # real pages can carry the protection's markers too (see #1289/#1292 for AA).
+ if challenge_marker(html) and _SITE_PAGE_MARKER not in html:
+ msg = (
+ "OceanofPDF answered with a Cloudflare challenge that was not cleared. "
+ "Check that the bypass is enabled and working."
+ )
+ raise DirectDownloadUnavailableError(msg)
+ return html
+
+ def _queries(self, plan: ReleaseSearchPlan) -> list[str]:
+ """Title + author first, then the bare title as a looser fallback.
+
+ WordPress search ANDs every word across the post, and each post names its author,
+ so adding the author narrows "Dune" from forty pages to the right book.
+ """
+ if plan.manual_query:
+ return [plan.manual_query]
+ queries: list[str] = []
+ for variant in plan.grouped_title_variants:
+ for query in (f"{variant.title} {plan.author}".strip(), variant.title.strip()):
+ if query and query not in queries:
+ queries.append(query)
+ return queries
+
+ def search(
+ self,
+ book: BookMetadata,
+ plan: ReleaseSearchPlan,
+ *,
+ expand_search: bool = False,
+ content_type: str = "ebook",
+ ) -> list[BrowseRecord]:
+ """Search OceanofPDF, stopping at the first query that finds anything."""
+ del book, expand_search, content_type
+ base_url = _base_url()
+ if not base_url:
+ return []
+
+ if plan.source_filters is not None:
+ filters = plan.source_filters
+ queries = [plan.manual_query] if plan.manual_query else []
+ else:
+ filters = SearchFilters(lang=plan.languages)
+ queries = self._queries(plan)
+
+ for query in queries:
+ if search_deadline.expired():
+ logger.info("Release search budget spent; skipping remaining OceanofPDF queries")
+ break
+ logger.debug("Searching OceanofPDF: %s", query)
+ records = parse_search_results(self._fetch_search_page(base_url, query), filters)
+ if records:
+ logger.info("Found %s OceanofPDF releases for %r", len(records), query)
+ return records
+ return []
+
+ # --- Download ---
+
+ def _resolve_file_url(
+ self,
+ action_url: str,
+ fields: dict[str, str],
+ book_url: str,
+ cancel_flag: Event | None,
+ ) -> tuple[str, int] | None:
+ """POST the download form and read the file URL off the interstitial."""
+ response = _post_fetch_form(action_url, fields, book_url)
+ if _is_challenge(response):
+ # The clearance expired between the book page and this POST. Bypassers only
+ # replay GETs, so re-solve on the book page and post again with fresh cookies.
+ logger.info("OceanofPDF download form was challenged; refreshing clearance")
+ downloader.html_get_page(book_url, cancel_flag=cancel_flag, use_bypasser=True)
+ response = _post_fetch_form(action_url, fields, book_url)
+ response.raise_for_status()
+ resolved = parse_file_redirect(response.text, response.url or action_url)
+ if resolved is None:
+ _log_unresolved_fetch_page(response)
+ return resolved
+
+ def download(
+ self,
+ book_info: BrowseRecord,
+ book_path: Path,
+ progress_callback: Callable[[float], None] | None,
+ cancel_flag: Event | None,
+ status_callback: Callable[[str, str | None], None] | None,
+ ) -> str | None:
+ """Download the requested format of an OceanofPDF book. Returns the file URL."""
+ book_url = book_info.source_url
+ if not book_url:
+ msg = f"OceanofPDF record {book_info.id!r} has no book page URL"
+ raise RuntimeError(msg)
+
+ def status(message: str) -> None:
+ if status_callback:
+ status_callback("resolving", message)
+
+ try:
+ status("Fetching OceanofPDF book page")
+ page = html_response_text(
+ downloader.html_get_page(
+ book_url,
+ cancel_flag=cancel_flag,
+ status_callback=status_callback,
+ allow_bypasser_fallback=True,
+ )
+ )
+ if not page:
+ logger.warning("OceanofPDF book page could not be fetched: %s", book_url)
+ return None
+
+ soup = BeautifulSoup(page, "html.parser")
+ form = pick_download_form(soup, book_info.format)
+ if form is None:
+ logger.warning(
+ "No OceanofPDF download form for format %r on %s", book_info.format, book_url
+ )
+ return None
+ action, fields = form
+ action_url = urljoin(book_url, action)
+
+ if cancel_flag and cancel_flag.is_set():
+ return None
+ status("Requesting OceanofPDF download link")
+ resolved = self._resolve_file_url(action_url, fields, book_url, cancel_flag)
+ if resolved is None:
+ logger.warning("OceanofPDF did not return a file link for %s", book_url)
+ return None
+ file_url, delay = resolved
+
+ wait = min(delay, _MAX_REDIRECT_DELAY_SECONDS)
+ if wait > 0:
+ status(f"OceanofPDF - Waiting {wait}s")
+ if cancel_flag and cancel_flag.wait(timeout=wait):
+ return None
+
+ logger.info("Resolved OceanofPDF file URL for %s", book_info.title)
+ data = downloader.download_url(
+ file_url,
+ file_size_from_page(soup, book_info.format) or book_info.size or "",
+ progress_callback,
+ cancel_flag,
+ None,
+ status_callback,
+ referer=action_url,
+ )
+ if not data:
+ logger.warning("No data received from OceanofPDF for %s", book_info.title)
+ return None
+
+ size = data.getbuffer().nbytes
+ data.seek(0)
+ head = data.read(64).lstrip().lower()
+ if size < MIN_VALID_FILE_SIZE or head.startswith(_HTML_SIGNATURES):
+ logger.warning(
+ "OceanofPDF returned an HTML page or a stub (%s bytes), not a book", size
+ )
+ return None
+
+ with book_path.open("wb") as f:
+ f.write(data.getbuffer())
+ except (requests.exceptions.RequestException, OSError, ValueError) as e:
+ logger.warning("OceanofPDF download failed for %s: %s", book_url, e)
+ return None
+ else:
+ return file_url
diff --git a/shelfmark/release_sources/direct_download/registry.py b/shelfmark/release_sources/direct_download/registry.py
index 9324f709..068a3272 100644
--- a/shelfmark/release_sources/direct_download/registry.py
+++ b/shelfmark/release_sources/direct_download/registry.py
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING
from shelfmark.core.config import config
from shelfmark.release_sources.direct_download.annas_archive import AnnasArchiveProvider
+from shelfmark.release_sources.direct_download.oceanofpdf import OceanofPDFProvider
if TYPE_CHECKING:
from collections.abc import Sequence
@@ -13,7 +14,7 @@ if TYPE_CHECKING:
from shelfmark.release_sources.direct_download.common import DirectDownloadProvider
-PROVIDER_TYPES = (AnnasArchiveProvider,)
+PROVIDER_TYPES = (AnnasArchiveProvider, OceanofPDFProvider)
_AA_MD5_PATTERN = re.compile(r"^[0-9a-f]{32}$", re.IGNORECASE)
@@ -38,8 +39,8 @@ def get_unavailable_reason(
return "Direct Download is disabled. Enable the source in Settings."
if not enabled_providers(providers):
return (
- "Direct Download is not configured. Add at least one Anna's Archive mirror URL in "
- "Settings. Z-Library, LibGen, and Welib mirrors only download results found through "
+ "Direct Download is not configured. Add at least one Anna's Archive or OceanofPDF mirror URL in "
+ "Settings. Z-Library, LibGen, Welib, mirrors only download results found through "
"Anna's Archive."
)
return None
diff --git a/tests/bypass/test_cloudflare_beacon.py b/tests/bypass/test_cloudflare_beacon.py
new file mode 100644
index 00000000..5e85fa65
--- /dev/null
+++ b/tests/bypass/test_cloudflare_beacon.py
@@ -0,0 +1,51 @@
+"""A rendered page carrying Cloudflare's bot-detection beacon is not a challenge.
+
+Cloudflare injects an iframe loading /cdn-cgi/challenge-platform/scripts/jsd/main.js into
+every page a protected site serves. The raw HTML has no such reference; the browser adds
+it after load. FlareSolverr returns the rendered DOM, so every successful OceanofPDF solve
+came back carrying "/cdn-cgi/challenge-platform" and was rejected as an unsolved
+challenge - 26 KB results pages, retried five times each, then reported as unreachable.
+"""
+
+from shelfmark.bypass.challenge import challenge_marker
+
+# Shape of a rendered OceanofPDF search page: the site's own markup plus the beacon.
+RENDERED_PAGE_WITH_BEACON = (
+ "You searched for dune - OceanofPDF"
+ 'Skip to main content'
+ ''
+ ''
+ ""
+ ""
+ ''
+ ""
+)
+
+# Cloudflare's managed challenge, trimmed to its markers. It can carry the beacon too.
+MANAGED_CHALLENGE = (
+ "Just a moment..."
+ ""
+ ''
+ ''
+ ""
+)
+
+
+def test_beacon_on_a_rendered_page_is_not_a_challenge():
+ assert challenge_marker(RENDERED_PAGE_WITH_BEACON) is None
+
+
+def test_beacon_path_in_any_case_is_ignored():
+ assert challenge_marker(RENDERED_PAGE_WITH_BEACON.replace("scripts/jsd", "Scripts/JSD")) is None
+
+
+def test_managed_challenge_is_still_caught_with_the_beacon_present():
+ assert challenge_marker(MANAGED_CHALLENGE) is not None
+
+
+def test_challenge_script_path_alone_is_still_caught():
+ page = ''
+
+ assert challenge_marker(page) == "/cdn-cgi/challenge-platform"
diff --git a/tests/direct_download/test_oceanofpdf.py b/tests/direct_download/test_oceanofpdf.py
new file mode 100644
index 00000000..88fa5c17
--- /dev/null
+++ b/tests/direct_download/test_oceanofpdf.py
@@ -0,0 +1,579 @@
+"""OceanofPDF provider: search parsing, download resolution and registry wiring.
+
+The fixtures mirror the live site's markup (Genesis WordPress theme) as of 2026-10:
+search results are posts, the book page carries one POST form per format, and
+the form's response is an interstitial with a meta refresh to a signed file URL.
+"""
+
+from io import BytesIO
+from threading import Event
+from types import SimpleNamespace
+
+import pytest
+from bs4 import BeautifulSoup
+
+from shelfmark.core import mirrors
+from shelfmark.core.models import SearchFilters
+from shelfmark.release_sources import BrowseRecord
+from shelfmark.release_sources.direct_download import oceanofpdf as op
+from shelfmark.release_sources.direct_download import registry
+from shelfmark.release_sources.direct_download.common import DirectDownloadUnavailableError
+
+BASE = "https://ocean.example"
+DUNE_URL = f"{BASE}/authors/frank-herbert/pdf-epub-dune-download-85663993149/"
+SONG_URL = f"{BASE}/authors/anissa-m-bouziane/pdf-epub-dune-song-download/"
+ART_URL = f"{BASE}/authors/alejandro-jodorowsky/pdf-dune-art-book-download/"
+FETCH_URL = f"{BASE}/Fetching_Resource.php"
+FILE_URL = "https://files.example/OceanofPDF.com/Dune_1_-_Frank_Herbert.epub?md5=abc&expires=1"
+
+
+def _article(url: str, title: str, label: str, summary: str, cover: str = "") -> str:
+ return f"""
+
+
+
+ """
+
+
+def _page(body: str) -> str:
+ return f"""OceanofPDF
+ Skip to main content
+ {body}"""
+
+
+SEARCH_PAGE = _page(
+ _article(
+ DUNE_URL,
+ "Dune",
+ "[PDF] [EPUB] Dune Download",
+ "[PDF] [EPUB] Dune Download by Frank Herbert. Download Dune by Frank Herbert in PDF "
+ "EPUB format complete free.",
+ cover="https://media.example/dune.jpg",
+ )
+ + _article(
+ SONG_URL,
+ "Dune Song",
+ "[PDF] [EPUB] Dune Song Download",
+ "[PDF] [EPUB] Dune Song Download by Anissa M. Bouziane. Download Dune Song by Anissa "
+ "M. Bouziane in PDF EPUB format complete free.",
+ )
+ + _article(
+ ART_URL,
+ "Dune Art Book",
+ "[PDF] Dune Art Book Download",
+ "[PDF] Dune Art Book Download by Alejandro Jodorowsky. Download Dune Art Book in PDF "
+ "format complete free.",
+ )
+)
+
+EMPTY_SEARCH_PAGE = _page("Nothing Found
")
+
+BOOK_PAGE = _page(
+ """
+
+
- Full Book Name: Dune
- Author Name: Frank Herbert
+ - PDF File Size: 2.1 MB
- EPUB File Size: 952 KB
+
+
+
+
"""
+)
+
+INTERSTITIAL_PAGE = _page(
+ f''
+ "Don't worry! Your download will still start.
"
+)
+
+CHALLENGE_PAGE = (
+ "Just a moment..."
+ ''
+ ""
+)
+
+
+@pytest.fixture
+def configured(monkeypatch):
+ """Configure an OceanofPDF mirror and keep every request off the network.
+
+ Returns the settings the provider reads, so a test can flip the toggle or the bypass.
+ """
+ settings = {
+ "USE_CF_BYPASS": True,
+ "SOURCE_PRIORITY": [{"id": "zlib", "enabled": True}, {"id": "oceanofpdf", "enabled": True}],
+ }
+ monkeypatch.setattr(
+ op, "config", SimpleNamespace(get=lambda key, default=None: settings.get(key, default))
+ )
+ monkeypatch.setattr(mirrors, "_get_config", lambda: {"OCEANOFPDF_MIRROR_URLS": [BASE]})
+ monkeypatch.setattr(op.network, "AAMirrorSelector", lambda: SimpleNamespace(last_failure=None))
+ monkeypatch.setattr(op.network, "get_proxies", lambda _url: {})
+ monkeypatch.setattr(op.network, "get_ssl_verify", lambda _url: True)
+ monkeypatch.setattr(op.downloader, "get_cf_cookies_for_domain", lambda _host: {})
+ monkeypatch.setattr(op.downloader, "get_cf_user_agent_for_domain", lambda _host: None)
+ return settings
+
+
+def _plan(**overrides):
+ values = {
+ "manual_query": None,
+ "source_filters": None,
+ "languages": None,
+ "author": "Frank Herbert",
+ "grouped_title_variants": [SimpleNamespace(title="Dune")],
+ }
+ values.update(overrides)
+ return SimpleNamespace(**values)
+
+
+def _serve_pages(monkeypatch, pages: dict[str, str]) -> list[str]:
+ """Answer html_get_page from ``pages`` by URL, recording each URL requested."""
+ requested: list[str] = []
+
+ def fake_html_get_page(url, *_args, **_kwargs):
+ requested.append(url)
+ return pages.get(url, "")
+
+ monkeypatch.setattr(op.downloader, "html_get_page", fake_html_get_page)
+ return requested
+
+
+# --- Search parsing ---
+
+
+def test_search_results_split_into_one_record_per_format():
+ records = op.parse_search_results(SEARCH_PAGE, SearchFilters(format=["pdf", "epub"]))
+
+ assert [(r.title, r.format) for r in records] == [
+ ("Dune", "pdf"),
+ ("Dune", "epub"),
+ ("Dune Song", "pdf"),
+ ("Dune Song", "epub"),
+ ("Dune Art Book", "pdf"),
+ ]
+ dune_pdf, dune_epub = records[0], records[1]
+ assert dune_pdf.id.startswith("oceanofpdf:")
+ assert dune_pdf.id != dune_epub.id
+ assert dune_pdf.source_url == DUNE_URL
+ assert dune_pdf.author == "Frank Herbert"
+ assert dune_pdf.preview == "https://media.example/dune.jpg"
+ assert dune_pdf.source == "direct_download"
+
+
+def test_record_ids_are_stable_across_searches():
+ first = op.parse_search_results(SEARCH_PAGE, SearchFilters())
+ second = op.parse_search_results(SEARCH_PAGE, SearchFilters())
+
+ assert [r.id for r in first] == [r.id for r in second]
+
+
+def test_author_with_initials_is_not_cut_at_the_first_full_stop():
+ records = op.parse_search_results(SEARCH_PAGE, SearchFilters())
+
+ assert {r.author for r in records if r.title == "Dune Song"} == {"Anissa M. Bouziane"}
+
+
+def test_unfiltered_search_keeps_only_the_configured_supported_formats(monkeypatch):
+ monkeypatch.setattr(
+ "shelfmark.release_sources.direct_download.common.get_supported_formats",
+ lambda: ["epub", "mobi"],
+ )
+
+ records = op.parse_search_results(SEARCH_PAGE, SearchFilters())
+
+ assert {r.format for r in records} == {"epub"}
+
+
+def test_format_filter_drops_other_formats():
+ records = op.parse_search_results(SEARCH_PAGE, SearchFilters(format=["epub"]))
+
+ assert {r.format for r in records} == {"epub"}
+ assert "Dune Art Book" not in {r.title for r in records}
+
+
+def test_results_without_a_language_survive_a_language_filter():
+ records = op.parse_search_results(SEARCH_PAGE, SearchFilters(lang=["en"]))
+
+ assert records
+
+
+def test_empty_search_page_parses_to_no_records():
+ assert op.parse_search_results(EMPTY_SEARCH_PAGE, SearchFilters()) == []
+
+
+# --- Book page and interstitial parsing ---
+
+
+def test_download_form_is_picked_by_format():
+ soup = BeautifulSoup(BOOK_PAGE, "html.parser")
+
+ action, fields = op.pick_download_form(soup, "epub")
+
+ assert action == FETCH_URL
+ assert fields == {"id": "srv3", "filename": "Dune_1_-_Frank_Herbert.epub"}
+
+
+def test_download_form_ignores_the_search_form_and_defaults_to_the_first():
+ soup = BeautifulSoup(BOOK_PAGE, "html.parser")
+
+ _action, fields = op.pick_download_form(soup, None)
+
+ assert fields["filename"].endswith(".pdf")
+
+
+def test_download_form_is_none_for_a_format_the_page_lacks():
+ soup = BeautifulSoup(BOOK_PAGE, "html.parser")
+
+ assert op.pick_download_form(soup, "mobi") is None
+
+
+def test_file_size_is_read_for_the_requested_format():
+ soup = BeautifulSoup(BOOK_PAGE, "html.parser")
+
+ assert op.file_size_from_page(soup, "epub") == "952 KB"
+ assert op.file_size_from_page(soup, "pdf") == "2.1 MB"
+ assert op.file_size_from_page(soup, "mobi") is None
+
+
+def test_meta_refresh_gives_the_file_url_and_delay():
+ html = f''
+
+ assert op.parse_file_redirect(html, FETCH_URL) == (FILE_URL, 5)
+
+
+def test_relative_refresh_target_is_resolved_against_the_page():
+ html = ''
+
+ assert op.parse_file_redirect(html, FETCH_URL) == (f"{BASE}/files/book.epub", 3)
+
+
+def test_plain_file_link_is_used_when_there_is_no_meta_refresh():
+ html = 'Download'
+
+ assert op.parse_file_redirect(html, FETCH_URL) == ("https://files.example/x/book.epub?md5=1", 0)
+
+
+def test_interstitial_without_a_file_link_resolves_to_none():
+ assert op.parse_file_redirect("Please donate
", FETCH_URL) is None
+
+
+# --- Provider search ---
+
+
+def test_search_queries_title_and_author_first(configured, monkeypatch):
+ requested = _serve_pages(monkeypatch, {f"{BASE}/?s=Dune+Frank+Herbert": SEARCH_PAGE})
+
+ records = op.OceanofPDFProvider().search(SimpleNamespace(), _plan())
+
+ assert requested == [f"{BASE}/?s=Dune+Frank+Herbert"]
+ assert records[0].title == "Dune"
+
+
+def test_search_falls_back_to_the_bare_title(configured, monkeypatch):
+ requested = _serve_pages(
+ monkeypatch,
+ {f"{BASE}/?s=Dune+Frank+Herbert": EMPTY_SEARCH_PAGE, f"{BASE}/?s=Dune": SEARCH_PAGE},
+ )
+
+ records = op.OceanofPDFProvider().search(SimpleNamespace(), _plan())
+
+ assert requested == [f"{BASE}/?s=Dune+Frank+Herbert", f"{BASE}/?s=Dune"]
+ assert records
+
+
+def test_manual_search_sends_the_query_as_typed(configured, monkeypatch):
+ requested = _serve_pages(monkeypatch, {f"{BASE}/?s=dune+song": SEARCH_PAGE})
+
+ op.OceanofPDFProvider().search(
+ SimpleNamespace(), _plan(manual_query="dune song", source_filters=SearchFilters())
+ )
+
+ assert requested == [f"{BASE}/?s=dune+song"]
+
+
+def test_unreachable_site_is_reported_not_treated_as_no_results(configured, monkeypatch):
+ _serve_pages(monkeypatch, {})
+
+ with pytest.raises(DirectDownloadUnavailableError, match="Unable to reach OceanofPDF"):
+ op.OceanofPDFProvider().search(SimpleNamespace(), _plan())
+
+
+def test_uncleared_challenge_is_reported(configured, monkeypatch):
+ _serve_pages(monkeypatch, {f"{BASE}/?s=Dune+Frank+Herbert": CHALLENGE_PAGE})
+
+ with pytest.raises(DirectDownloadUnavailableError, match="Cloudflare challenge"):
+ op.OceanofPDFProvider().search(SimpleNamespace(), _plan())
+
+
+def test_real_page_carrying_challenge_markers_is_not_misread(configured, monkeypatch):
+ page = SEARCH_PAGE.replace("