import asyncio
import re
from urllib.parse import urljoin, quote, urlencode, parse_qs, urlsplit, urlunsplit
import solara
from bs4 import BeautifulSoup
from pyodide.http import pyfetch
# progressive browser API access
try:
import js # pyodide
except Exception:
js = None
SERIES_INDEX_URL = "https://tcbonepiecechapters.com/mangas/5/one-piece"
CHAPTER_URL_TEMPLATE = "https://tcbonepiecechapters.com/chapters/{uid}/one-piece-chapter-{chapter}"
IMG_EXT_RE = r"\.(?:jpg|jpeg|png|webp)(?:\?[^\"'\s>]*)?(?=$|[\"'\s>])"
_UID_CACHE: dict[str, str] = {}
def looks_like_page_img(src: str | None) -> bool:
if not src:
return False
if not re.search(IMG_EXT_RE, src, re.I):
return False
low = src.lower()
for bad in ("logo","icon","favicon","sprite","banner","ads","advert","thumbnail","thumb","social"):
if bad in low:
return False
return True
def pick_from_srcset(srcset: str, page_url: str) -> str | None:
best, best_w = None, -1
for part in srcset.split(","):
bits = part.strip().split()
if not bits:
continue
url = urljoin(page_url, bits[0])
w = 0
if len(bits) > 1 and bits[1].endswith("w"):
try:
w = int(bits[1][:-1])
except Exception:
w = 0
if looks_like_page_img(url) and w >= best_w:
best, best_w = url, w
return best
def extract_img_urls(page_url: str, html: str) -> list[str]:
soup = BeautifulSoup(html, "html.parser")
reader = None
for sel in ("#readerarea", ".reader-area", ".reading-content", "#content", "main", "article"):
reader = soup.select_one(sel)
if reader:
break
root = reader if reader else soup
candidates = []
for img in root.find_all("img"):
src = img.get("src") or img.get("data-src") or img.get("data-lazy-src") or img.get("data-original")
if not src and img.get("srcset"):
chosen = pick_from_srcset(img.get("srcset"), page_url)
if chosen:
src = chosen
if src:
u = urljoin(page_url, src)
if looks_like_page_img(u):
candidates.append(u)
for s in root.find_all("source"):
ss = s.get("srcset") or s.get("data-srcset")
if ss:
u = pick_from_srcset(ss, page_url)
if u and looks_like_page_img(u):
candidates.append(u)
for no in root.find_all("noscript"):
try:
ns = BeautifulSoup(no.decode_contents(), "html.parser")
for img in ns.find_all("img"):
src = img.get("src") or img.get("data-src")
if src:
u = urljoin(page_url, src)
if looks_like_page_img(u):
candidates.append(u)
except Exception:
pass
rx = re.compile(r"https?://[^\s\"']+?" + IMG_EXT_RE, re.I)
for m in rx.finditer(html):
u = m.group(0)
if looks_like_page_img(u):
candidates.append(u)
seen, ordered = set(), []
for u in candidates:
if u not in seen:
seen.add(u)
ordered.append(u)
return ordered
def build_proxied_url(proxy_pattern: str, target: str) -> str:
p = proxy_pattern.strip()
if not p:
return target
if "{url}" in p:
return p.replace("{url}", quote(target, safe=""))
if p.endswith("?url="):
return p + quote(target, safe="")
return p.rstrip("/") + "/" + target
def normalize_chapter_input(s: str) -> str:
m = re.search(r"(\d{3,5})", s.strip())
return m.group(1) if m else ""
async def fetch_text(url: str) -> tuple[int, str]:
r = await pyfetch(url, method="GET")
return r.status, await r.string()
async def build_uid_map(proxy_pattern: str) -> dict[str, str]:
if _UID_CACHE:
return _UID_CACHE
url = build_proxied_url(proxy_pattern, SERIES_INDEX_URL)
status, html = await fetch_text(url)
if status >= 400:
return {}
soup = BeautifulSoup(html, "html.parser")
rx = re.compile(r"/chapters/(\d+)/one-piece-chapter-(\d+)", re.I)
for a in soup.find_all("a", href=True):
m = rx.search(a["href"])
if m:
uid, chap = m.group(1), m.group(2)
_UID_CACHE[chap] = uid
return _UID_CACHE
async def resolve_chapter_url(chapter: str, proxy_pattern: str) -> tuple[str, str]:
uid_map = await build_uid_map(proxy_pattern)
uid = uid_map.get(chapter)
if not uid:
tentative = CHAPTER_URL_TEMPLATE.format(uid="7846", chapter=chapter)
url1 = build_proxied_url(proxy_pattern, tentative)
s, h = await fetch_text(url1)
return tentative, h
resolved = CHAPTER_URL_TEMPLATE.format(uid=uid, chapter=chapter)
url2 = build_proxied_url(proxy_pattern, resolved)
s2, h2 = await fetch_text(url2)
return resolved, h2
def get_system_prefers_dark() -> bool:
try:
return bool(js and getattr(js, "window", None) and js.window.matchMedia("(prefers-color-scheme: dark)").matches)
except Exception:
return False
def read_url_state() -> dict:
try:
if not js or not getattr(js, "window", None):
return {}
href = str(js.window.location.href)
qs = urlsplit(href).query
return {k: v[0] for k, v in parse_qs(qs).items()}
except Exception:
return {}
def write_url_state(ch: str, p: int, mode: str):
try:
if not js or not getattr(js, "window", None):
return
parts = list(urlsplit(str(js.window.location.href)))
q = parse_qs(parts[3])
q["ch"] = [ch]
q["p"] = [str(p)]
q["mode"] = [mode]
parts[3] = urlencode(q, doseq=True)
new_url = urlunsplit(parts)
js.window.history.replaceState(None, "", new_url)
except Exception:
pass
@solara.component
def Page():
chapter_input = solara.use_reactive("1152")
proxy = solara.use_reactive("https://mirrorfoundation.nl/_functions/proxy?url=")
view_mode = solara.use_reactive("Pager") # "Pager" or "Strip"
status = solara.use_reactive("")
error = solara.use_reactive("")
used_url = solara.use_reactive("")
pages = solara.use_reactive([]) # list[str]
page_ix = solara.use_reactive(0) # 0-based
current_src = solara.use_reactive("") # for Pager
# Theme: defaults to system if detectable
theme_mode = solara.use_reactive("system")
system_is_dark = get_system_prefers_dark()
is_dark = (theme_mode.value == "dark") or (theme_mode.value == "system" and system_is_dark)
next_ch_first = solara.use_reactive("")
kb_bound = solara.use_reactive(False)
def _sync_current():
if pages.value and 0 <= page_ix.value < len(pages.value):
current_src.set(pages.value[page_ix.value])
else:
current_src.set("")
solara.use_effect(_sync_current, [pages.value, page_ix.value])
def _persist_url():
ch = normalize_chapter_input(chapter_input.value) or "1"
write_url_state(ch, page_ix.value + 1, view_mode.value)
solara.use_effect(_persist_url, [chapter_input.value, page_ix.value, view_mode.value])
initialized = solara.use_reactive(False)
def _init_from_url():
if initialized.value:
return
s = read_url_state()
if "ch" in s and s["ch"].isdigit():
chapter_input.set(s["ch"])
if "mode" in s and s["mode"] in ("Pager", "Strip"):
view_mode.set(s["mode"])
initialized.set(True)
solara.use_effect(_init_from_url, [initialized.value])
async def load_chapter(ch_str: str, desired_page_1based: int | None = None):
error.set("")
status.set("Loading…")
pages.set([])
page_ix.set(0)
used_url.set("")
current_src.set("")
next_ch_first.set("")
resolved_url, html = await resolve_chapter_url(ch_str, proxy.value)
used_url.set(resolved_url)
urls = extract_img_urls(resolved_url, html)
if not urls:
status.set("No pages found.")
return
proxied = [build_proxied_url(proxy.value, u) for u in urls]
pages.set(proxied)
status.set(f"{len(proxied)} pages")
if desired_page_1based and proxied:
pi = max(1, min(desired_page_1based, len(proxied))) - 1
page_ix.set(pi)
# gentle prefetch: next chapter's first page
try:
nxt = str(max(1, int(ch_str) + 1))
resolved2, html2 = await resolve_chapter_url(nxt, proxy.value)
img2 = extract_img_urls(resolved2, html2)
if img2:
next_ch_first.set(build_proxied_url(proxy.value, img2[0]))
except Exception:
pass
async def fetch_and_render(from_url=False):
chap = normalize_chapter_input(chapter_input.value)
if not chap:
error.set("Enter a valid chapter number.")
return
desired_p = None
if from_url:
s = read_url_state()
if "p" in s and s["p"].isdigit():
desired_p = int(s["p"])
await load_chapter(chap, desired_p)
# keyboard shortcuts: bind only if js.document exists
def _bind_keyboard():
if kb_bound.value or not (js and getattr(js, "document", None)):
return
def on_keydown(ev):
try:
key = (ev.key or "").lower()
if view_mode.value != "Pager":
return
if key in ("arrowright", "d", "k"):
ev.preventDefault(); next_page()
elif key in ("arrowleft", "a", "j"):
ev.preventDefault(); prev_page()
except Exception:
pass
js.document.addEventListener("keydown", on_keydown, False)
kb_bound.set(True)
solara.use_effect(_bind_keyboard, [kb_bound.value, view_mode.value])
async def test_proxy():
error.set("")
status.set("Testing…")
try:
r = await pyfetch(build_proxied_url(proxy.value, "https://example.com/"), method="GET")
status.set(f"Proxy OK: {r.status}")
except Exception as e:
error.set(f"Proxy failed: {e}")
status.set("")
def on_fetch_click():
asyncio.create_task(fetch_and_render(from_url=True if not pages.value else False))
def change_chapter(delta: int):
m = re.search(r"(\d{3,5})", chapter_input.value.strip())
if not m:
return
n = max(1, int(m.group(1)) + delta)
chapter_input.set(str(n))
asyncio.create_task(load_chapter(str(n)))
def next_page():
if not pages.value:
return
if page_ix.value + 1 < len(pages.value):
page_ix.set(page_ix.value + 1)
else:
change_chapter(+1)
def prev_page():
if not pages.value:
return
if page_ix.value > 0:
page_ix.set(page_ix.value - 1)
else:
m = re.search(r"(\d{3,5})", chapter_input.value.strip())
if m and int(m.group(1)) > 1:
pch = str(int(m.group(1)) - 1)
chapter_input.set(pch)
async def _load_and_last():
await load_chapter(pch)
if pages.value:
page_ix.set(max(0, len(pages.value) - 1))
asyncio.create_task(_load_and_last())
def go_to_page_str(s: str):
if not pages.value:
return
if s.isdigit():
i = max(1, min(int(s), len(pages.value)))
page_ix.set(i - 1)
# Theme switcher (defaults to system)
def cycle_theme():
if theme_mode.value == "system":
theme_mode.set("dark" if not system_is_dark else "light")
elif theme_mode.value == "dark":
theme_mode.set("light")
else:
theme_mode.set("system")
# ---- UI ----
bg = "#0b0b0b" if is_dark else "#ffffff"
fg = "#eaeaea" if is_dark else "#111111"
card_bg = "#0f0f10" if is_dark else "#ffffff"
card_border = "1px solid rgba(255,255,255,0.08)" if is_dark else "1px solid rgba(0,0,0,0.06)"
with solara.Column(style={"maxWidth": "980px","margin":"0 auto","padding":"1rem","display":"flex","gap":"0.75rem",
"backgroundColor": bg, "color": fg, "borderRadius":"8px"}):
with solara.Row(style={"display":"flex","gap":"0.5rem","flexWrap":"wrap"}):
solara.InputText(label="Chapter", value=chapter_input.value, on_value=chapter_input.set)
solara.Button("Prev chap", on_click=lambda: change_chapter(-1))
solara.Button("Next chap", on_click=lambda: change_chapter(+1))
solara.Button("Fetch", on_click=on_fetch_click)
solara.Button("Proxy test", on_click=lambda: asyncio.create_task(test_proxy()))
solara.InputText(label="Proxy", value=proxy.value, on_value=proxy.set)
solara.Button(f"Theme: {theme_mode.value.title()}", on_click=cycle_theme)
solara.Button("Pager", on_click=lambda: view_mode.set("Pager"))
solara.Button("Strip", on_click=lambda: view_mode.set("Strip"))
if used_url.value:
solara.Text(used_url.value)
if status.value:
solara.Text(status.value)
if error.value:
solara.Text(error.value, style={"color": "#ff6961"})
# Pager
if view_mode.value == "Pager" and current_src.value:
with solara.Row(style={"display":"flex","gap":"0.5rem","alignItems":"center","flexWrap":"wrap"}):
solara.Button("◀ Prev", on_click=prev_page)
solara.Text(f"{page_ix.value + 1} / {len(pages.value)}")
solara.InputText(label="Go to", value=str(page_ix.value + 1), on_value=go_to_page_str)
solara.Button("Next ▶", on_click=next_page)
with solara.Card(style={"padding":"0","overflow":"hidden","marginBottom":"0.5rem",
"backgroundColor": card_bg, "border": card_border}):
solara.Markdown(f'<img src="{current_src.value}" loading="eager" style="width:100%;height:auto;display:block;" />')
# prefetch next page (hidden)
if page_ix.value + 1 < len(pages.value):
nxt = pages.value[page_ix.value + 1]
solara.Markdown(f'<img src="{nxt}" loading="eager" style="width:1px;height:1px;opacity:0;" />')
# Strip
if view_mode.value == "Strip" and pages.value:
for src in pages.value:
with solara.Card(style={"padding":"0","overflow":"hidden","marginBottom":"0.5rem",
"backgroundColor": card_bg, "border": card_border}):
solara.Markdown(f'<img src="{src}" loading="lazy" style="width:100%;height:auto;display:block;" />')
# prefetch next chapter first page (hidden)
if next_ch_first.value:
solara.Markdown(f'<img src="{next_ch_first.value}" loading="eager" style="width:1px;height:1px;opacity:0;" />')
# auto-load on first mount (from URL if present; safe if js/window absent)
solara.use_effect(lambda: asyncio.create_task(fetch_and_render(from_url=True)), [])