init commit
This commit is contained in:
@@ -0,0 +1,329 @@
|
||||
# rule34vault downloader ideas
|
||||
|
||||
## User requirements / comments to keep
|
||||
|
||||
- Download bookmarks for a public username; no authorization required.
|
||||
- Bookmark pages are available as `https://rule34vault.com/u/<username>?page=<n>`.
|
||||
- Only username should be required for normal use.
|
||||
- Use browser-like headers to reduce chance of request blocking/blocklisting.
|
||||
- Do **not** rely on `HEAD` only. Some servers may reject or mis-handle `HEAD`; use `GET` with streaming/range checks when needed.
|
||||
- For normal image posts, first try predictable original-file URLs:
|
||||
- `https://rule34vault.com/posts/<prefix>/<post_id>/<post_id>.jpg`
|
||||
- `https://r34xyz.b-cdn.net/posts/<prefix>/<post_id>/<post_id>.jpg`
|
||||
- `<prefix>` is the first digits group from the post id path. Example: post `1263322` uses prefix `1263`.
|
||||
- Some videos require opening the post page and extracting real `<video>/<source src=...>` URLs from the rendered HTML.
|
||||
- Create a plain text list of downloaded files in the same order they appear in bookmarks.
|
||||
- Plain txt state/log files are enough. Do **not** use SQLite.
|
||||
- Use multiple tries and exponential sleep after failures.
|
||||
- Project must use a new venv.
|
||||
|
||||
## Real site structure inspected
|
||||
|
||||
Inspected with browser-like headers from the project venv.
|
||||
|
||||
### Video post example
|
||||
|
||||
URL:
|
||||
|
||||
```text
|
||||
https://rule34vault.com/post/738794
|
||||
```
|
||||
|
||||
The page contains a real video element in returned HTML:
|
||||
|
||||
```html
|
||||
<video controls loop class="video" style="aspect-ratio: 1280/720;">
|
||||
<source type="video/mp4" src="https://r34xyz.b-cdn.net/posts/738/738794/738794.480.mp4">
|
||||
</video>
|
||||
```
|
||||
|
||||
So video extraction should parse the post HTML with BeautifulSoup and collect:
|
||||
|
||||
1. `video[src]`
|
||||
2. `video source[src]`
|
||||
3. fallback: any `source[src]` where `type` starts with `video/`
|
||||
|
||||
For post `996379`, real page had multiple video sources:
|
||||
|
||||
```text
|
||||
https://r34xyz.b-cdn.net/posts/996/996379/996379.720.hevc.mp4
|
||||
https://r34xyz.b-cdn.net/posts/996/996379/996379.480.mp4
|
||||
```
|
||||
|
||||
Prefer broadly-compatible sources first, e.g. non-HEVC MP4 before HEVC if both exist.
|
||||
|
||||
### Image post examples
|
||||
|
||||
For:
|
||||
|
||||
```text
|
||||
https://rule34vault.com/post/1263322
|
||||
```
|
||||
|
||||
The page HTML contains a small display image:
|
||||
|
||||
```html
|
||||
<img class="img" src="/posts/1263/1263322/1263322.small.jpg">
|
||||
```
|
||||
|
||||
But the original image is available directly:
|
||||
|
||||
```text
|
||||
https://rule34vault.com/posts/1263/1263322/1263322.jpg
|
||||
https://r34xyz.b-cdn.net/posts/1263/1263322/1263322.jpg
|
||||
```
|
||||
|
||||
Both returned `200 image/jpeg` during inspection.
|
||||
|
||||
Another inspected example:
|
||||
|
||||
```text
|
||||
https://rule34vault.com/post/1263303
|
||||
```
|
||||
|
||||
Sometimes the post page `img[src]` points only to a small/preview file:
|
||||
|
||||
```text
|
||||
https://rule34vault.com/posts/1263/1263303/1263303.small.jpg
|
||||
```
|
||||
|
||||
In this case the downloader must normalize the source URL to the original filename by removing the size suffix before the extension:
|
||||
|
||||
```text
|
||||
https://rule34vault.com/posts/1263/1263303/1263303.jpg
|
||||
```
|
||||
|
||||
This URL returned `200 image/jpeg` during inspection. Do not download `.small.jpg` when the corresponding original image exists.
|
||||
|
||||
### Bookmark page structure
|
||||
|
||||
On:
|
||||
|
||||
```text
|
||||
https://rule34vault.com/u/krosh?page=6
|
||||
```
|
||||
|
||||
Post links were **not** regular `<a href="/post/...">` links in the DOM. The bookmark data was inside:
|
||||
|
||||
```html
|
||||
<script id="ng-state" type="application/json">...</script>
|
||||
```
|
||||
|
||||
Relevant key looked like:
|
||||
|
||||
```json
|
||||
"post:/api/v2/post/search/bookmarked/50311": {
|
||||
"items": [
|
||||
{"id": 1075647, "type": 0, ...},
|
||||
{"id": 1081537, "type": 0, ...},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Therefore bookmark extraction should prefer parsing `script#ng-state` JSON and reading `items` from the key starting with:
|
||||
|
||||
```text
|
||||
post:/api/v2/post/search/bookmarked/
|
||||
```
|
||||
|
||||
Keep item order exactly as found in that `items` array.
|
||||
|
||||
The API also works directly as POST:
|
||||
|
||||
```text
|
||||
POST https://rule34vault.com/api/v2/post/search/bookmarked/<user_id>
|
||||
```
|
||||
|
||||
Examples observed:
|
||||
|
||||
- Body `{"skip": 100, "take": 20}` returned 20 bookmark items.
|
||||
- Body `{"take": 20, "page": 6}` returned page-like bookmark items.
|
||||
|
||||
Simplest robust path:
|
||||
|
||||
1. Fetch profile page `/u/<username>?page=<page>`.
|
||||
2. Parse `ng-state`.
|
||||
3. Get user id from key `get:/api/v2/account/user/<username>`.
|
||||
4. Get bookmark items from `post:/api/v2/post/search/bookmarked/<user_id>`.
|
||||
5. Optionally use the direct POST API for pagination once user id is known.
|
||||
|
||||
## Headers
|
||||
|
||||
Use one shared `requests.Session()` with realistic headers:
|
||||
|
||||
```python
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": "https://rule34vault.com/",
|
||||
}
|
||||
```
|
||||
|
||||
For direct API POST, use:
|
||||
|
||||
```python
|
||||
API_HEADERS = {
|
||||
**HEADERS,
|
||||
"Accept": "application/json,text/plain,*/*",
|
||||
"Origin": "https://rule34vault.com",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
```
|
||||
|
||||
For media download, set referer to the post page:
|
||||
|
||||
```python
|
||||
headers = {**HEADERS, "Referer": f"https://rule34vault.com/post/{post_id}"}
|
||||
```
|
||||
|
||||
## Retry policy
|
||||
|
||||
Every network operation should use retry wrapper:
|
||||
|
||||
```python
|
||||
for attempt in range(max_tries):
|
||||
try:
|
||||
...
|
||||
except requests.RequestException:
|
||||
sleep = base_delay * (2 ** attempt)
|
||||
time.sleep(sleep)
|
||||
```
|
||||
|
||||
Also retry on temporary HTTP statuses:
|
||||
|
||||
```text
|
||||
429, 500, 502, 503, 504
|
||||
```
|
||||
|
||||
Do not retry permanent `404` for candidate media URLs; just try the next candidate.
|
||||
|
||||
## Download order log
|
||||
|
||||
Create a txt manifest in download order:
|
||||
|
||||
```text
|
||||
download_order.txt
|
||||
```
|
||||
|
||||
Append one line per successfully downloaded post, exactly in bookmark order:
|
||||
|
||||
```text
|
||||
000001 1075647 downloads/1075/1075647.jpg https://rule34vault.com/posts/1075/1075647/1075647.jpg
|
||||
000002 1081537 downloads/1081/1081537.jpg https://r34xyz.b-cdn.net/posts/1081/1081537/1081537.jpg
|
||||
000003 996379 downloads/996/996379.mp4 https://r34xyz.b-cdn.net/posts/996/996379/996379.480.mp4
|
||||
```
|
||||
|
||||
Also keep simple txt logs:
|
||||
|
||||
```text
|
||||
seen.txt # post ids already processed successfully
|
||||
failed.txt # post id + reason
|
||||
skipped.txt # already existing files
|
||||
```
|
||||
|
||||
No SQLite.
|
||||
|
||||
## Media resolution strategy
|
||||
|
||||
For each bookmark item in order:
|
||||
|
||||
1. If already downloaded, skip and log to `skipped.txt`.
|
||||
2. If `type == 0` image:
|
||||
- Try original candidate URLs by extension and host.
|
||||
- Use streaming `GET`, not only `HEAD`.
|
||||
- Validate `status_code == 200` and `Content-Type` starts with `image/`.
|
||||
3. If candidates fail, fetch post page and parse actual HTML.
|
||||
- For image posts, inspect `img[src]` values from the real post page.
|
||||
- If the only discovered image is a display derivative such as `<post_id>.small.jpg`, `<post_id>.preview.jpg`, `<post_id>.thumbnail.jpg`, etc., derive and try the original URL without the size suffix before downloading:
|
||||
- `1263303.small.jpg` -> `1263303.jpg`
|
||||
- `1263303.preview.jpg` -> `1263303.jpg`
|
||||
- Download the higher-resolution original when it returns a valid image response; use the derivative only as a last fallback if the original does not exist.
|
||||
4. For videos (`type == 1`) or if image candidates fail:
|
||||
- Fetch `https://rule34vault.com/post/<post_id>`.
|
||||
- Parse `<video>` and `<source>` tags.
|
||||
- Choose best compatible source.
|
||||
5. Download media to grouped directory:
|
||||
|
||||
```text
|
||||
downloads/<prefix>/<post_id>.<ext>
|
||||
```
|
||||
|
||||
## Candidate URL generation
|
||||
|
||||
For post id `1263322`:
|
||||
|
||||
```python
|
||||
prefix = post_id[:4]
|
||||
```
|
||||
|
||||
Candidate originals:
|
||||
|
||||
```python
|
||||
hosts = [
|
||||
"https://rule34vault.com",
|
||||
"https://r34xyz.b-cdn.net",
|
||||
]
|
||||
|
||||
extensions = ["jpg", "png", "webp", "gif"]
|
||||
|
||||
for host in hosts:
|
||||
for ext in extensions:
|
||||
yield f"{host}/posts/{prefix}/{post_id}/{post_id}.{ext}"
|
||||
```
|
||||
|
||||
For video direct guesses, possible candidates can be tried after HTML parsing or as fallback:
|
||||
|
||||
```text
|
||||
<post_id>.480.mp4
|
||||
<post_id>.720.mp4
|
||||
<post_id>.720.hevc.mp4
|
||||
<post_id>.webm
|
||||
```
|
||||
|
||||
But actual post HTML source extraction is preferred for videos.
|
||||
|
||||
## CLI idea
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install requests beautifulsoup4 tqdm
|
||||
python r34vault_downloader.py krosh --start-page 1 --end-page 10 --out downloads
|
||||
```
|
||||
|
||||
Arguments:
|
||||
|
||||
```text
|
||||
username required
|
||||
--start-page default 1
|
||||
--end-page optional; stop when no items if omitted
|
||||
--out default downloads
|
||||
--tries default 4
|
||||
--delay default 0.5 seconds between posts
|
||||
--base-retry-delay default 1.5 seconds
|
||||
--dry-run print planned downloads without saving media
|
||||
```
|
||||
|
||||
## Core functions
|
||||
|
||||
```python
|
||||
make_session()
|
||||
request_with_retries(method, url, **kwargs)
|
||||
fetch_user_page(username, page)
|
||||
parse_ng_state(html)
|
||||
extract_user_id(ng_state, username)
|
||||
extract_bookmark_items_from_state(ng_state, user_id)
|
||||
fetch_bookmark_items_api(user_id, page=None, skip=None, take=20)
|
||||
candidate_image_urls(post_id)
|
||||
stream_download(url, dest, referer)
|
||||
extract_media_sources_from_post_html(post_id)
|
||||
choose_video_source(sources)
|
||||
already_downloaded(post_id, out_dir)
|
||||
append_download_order(index, post_id, path, source_url)
|
||||
append_failed(post_id, reason)
|
||||
main()
|
||||
```
|
||||
@@ -1,2 +1,72 @@
|
||||
# rule34vault-downloader
|
||||
just python script to download your bookmarks from rule34.vault
|
||||
|
||||
Python script to download public bookmarks from `rule34vault.com` for a username. No authorization is required for public bookmark pages.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Download bookmarks for a user:
|
||||
|
||||
```bash
|
||||
python r34vault_downloader.py <user> --out downloads
|
||||
```
|
||||
|
||||
Download only a page range:
|
||||
|
||||
```bash
|
||||
python r34vault_downloader.py <user> --start-page 1 --end-page 3 --out downloads
|
||||
```
|
||||
|
||||
Process only the first N bookmark items, useful for testing:
|
||||
|
||||
```bash
|
||||
python r34vault_downloader.py <user> --start-page 6 --end-page 6 --limit 1 --out downloads
|
||||
```
|
||||
|
||||
## Output
|
||||
|
||||
Files are grouped by Rule34Vault post prefix:
|
||||
|
||||
```text
|
||||
downloads/
|
||||
1263/
|
||||
1263322.jpg
|
||||
996/
|
||||
996379.mp4
|
||||
download_order.txt
|
||||
seen.txt
|
||||
failed.txt
|
||||
skipped.txt
|
||||
```
|
||||
|
||||
`download_order.txt` records successful downloads in bookmark order:
|
||||
|
||||
```text
|
||||
000001 1263322 downloads/1263/1263322.jpg https://rule34vault.com/posts/1263/1263322/1263322.jpg
|
||||
```
|
||||
|
||||
## Behavior
|
||||
|
||||
- Uses browser-like headers and post-page `Referer` headers.
|
||||
- Uses streaming `GET`; it does not rely on `HEAD` requests.
|
||||
- Parses bookmark data from `script#ng-state` and preserves bookmark order.
|
||||
- Tries original image URLs first, for example `1263322.jpg`.
|
||||
- If a page image is only `1263303.small.jpg`, it tries `1263303.jpg` before falling back to the small file.
|
||||
- For videos, parses real `<video>` and `<source src="...">` elements from the post page.
|
||||
- Retries transient failures with exponential backoff.
|
||||
- Uses plain text logs only; no SQLite database.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
. .venv/bin/activate
|
||||
pip install pytest
|
||||
pytest -q
|
||||
```
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Download public Rule34Vault bookmarks for a username.
|
||||
|
||||
Usage:
|
||||
python r34vault_downloader.py krosh --start-page 1 --end-page 3 --out downloads
|
||||
|
||||
The downloader uses public pages/API only; no credentials are required.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import mimetypes
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Iterator
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
from requests import Response, Session
|
||||
from tqdm import tqdm
|
||||
|
||||
SITE = "https://rule34vault.com"
|
||||
CDN = "https://r34xyz.b-cdn.net"
|
||||
RETRY_STATUSES = {429, 500, 502, 503, 504}
|
||||
IMAGE_EXTS = ("jpg", "png", "webp", "gif", "jpeg")
|
||||
VIDEO_EXTS = ("mp4", "webm", "mov", "m4v")
|
||||
DERIVATIVE_SUFFIXES = ("small", "preview", "thumbnail", "thumb", "medium", "large")
|
||||
|
||||
HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||
),
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
|
||||
"Accept-Language": "en-US,en;q=0.9",
|
||||
"Connection": "keep-alive",
|
||||
"Referer": f"{SITE}/",
|
||||
}
|
||||
|
||||
API_HEADERS = {
|
||||
**HEADERS,
|
||||
"Accept": "application/json,text/plain,*/*",
|
||||
"Origin": SITE,
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MediaSources:
|
||||
images: list[str]
|
||||
image_fallbacks: list[str]
|
||||
videos: list[str]
|
||||
|
||||
|
||||
def make_session() -> Session:
|
||||
session = requests.Session()
|
||||
session.headers.update(HEADERS)
|
||||
return session
|
||||
|
||||
|
||||
def request_with_retries(
|
||||
session: Session,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
tries: int = 4,
|
||||
base_retry_delay: float = 1.5,
|
||||
retry_statuses: set[int] = RETRY_STATUSES,
|
||||
**kwargs: Any,
|
||||
) -> Response:
|
||||
"""Run an HTTP request with exponential backoff for transient failures."""
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(tries):
|
||||
try:
|
||||
response = session.request(method, url, **kwargs)
|
||||
if response.status_code not in retry_statuses:
|
||||
return response
|
||||
response.close()
|
||||
last_exc = RuntimeError(f"HTTP {response.status_code} for {url}")
|
||||
except requests.RequestException as exc:
|
||||
last_exc = exc
|
||||
if attempt < tries - 1:
|
||||
time.sleep(base_retry_delay * (2**attempt))
|
||||
if last_exc:
|
||||
raise last_exc
|
||||
raise RuntimeError(f"request failed without exception: {method} {url}")
|
||||
|
||||
|
||||
def fetch_user_page(session: Session, username: str, page: int, **retry_kwargs: Any) -> str:
|
||||
url = f"{SITE}/u/{username}?page={page}"
|
||||
response = request_with_retries(session, "GET", url, headers={**HEADERS, "Referer": f"{SITE}/"}, timeout=30, **retry_kwargs)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
|
||||
def parse_ng_state(html: str) -> dict[str, Any]:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
script = soup.find("script", id="ng-state", type="application/json") or soup.find("script", id="ng-state")
|
||||
if not script:
|
||||
raise ValueError("script#ng-state was not found")
|
||||
text = script.string or script.get_text() or ""
|
||||
if not text.strip():
|
||||
raise ValueError("script#ng-state is empty")
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def extract_user_id(state: dict[str, Any], username: str) -> int:
|
||||
key = f"get:/api/v2/account/user/{username}"
|
||||
value = state.get(key)
|
||||
if isinstance(value, dict):
|
||||
for field in ("id", "userId"):
|
||||
if isinstance(value.get(field), int):
|
||||
return value[field]
|
||||
data = value.get("data")
|
||||
if isinstance(data, dict):
|
||||
for field in ("userId", "id"):
|
||||
if isinstance(data.get(field), int):
|
||||
return data[field]
|
||||
# Fallback for case variations or encoded usernames.
|
||||
suffix = f"/api/v2/account/user/{username}".lower()
|
||||
for candidate_key, candidate_value in state.items():
|
||||
if candidate_key.lower().endswith(suffix) and isinstance(candidate_value, dict):
|
||||
data = candidate_value.get("data") if isinstance(candidate_value.get("data"), dict) else candidate_value
|
||||
for field in ("userId", "id"):
|
||||
if isinstance(data.get(field), int):
|
||||
return data[field]
|
||||
raise ValueError(f"could not find public user id for {username!r} in ng-state")
|
||||
|
||||
|
||||
def extract_bookmark_items_from_state(state: dict[str, Any], user_id: int) -> list[dict[str, Any]]:
|
||||
exact = f"post:/api/v2/post/search/bookmarked/{user_id}"
|
||||
value = state.get(exact)
|
||||
if isinstance(value, dict) and isinstance(value.get("items"), list):
|
||||
return list(value["items"])
|
||||
|
||||
prefix = "post:/api/v2/post/search/bookmarked/"
|
||||
for key, candidate in state.items():
|
||||
if key.startswith(prefix) and isinstance(candidate, dict) and isinstance(candidate.get("items"), list):
|
||||
return list(candidate["items"])
|
||||
return []
|
||||
|
||||
|
||||
def fetch_bookmark_items_api(
|
||||
session: Session,
|
||||
user_id: int,
|
||||
*,
|
||||
page: int | None = None,
|
||||
skip: int | None = None,
|
||||
take: int = 20,
|
||||
**retry_kwargs: Any,
|
||||
) -> list[dict[str, Any]]:
|
||||
body: dict[str, int] = {"take": take}
|
||||
if page is not None:
|
||||
body["page"] = page
|
||||
if skip is not None:
|
||||
body["skip"] = skip
|
||||
url = f"{SITE}/api/v2/post/search/bookmarked/{user_id}"
|
||||
response = request_with_retries(session, "POST", url, headers=API_HEADERS, json=body, timeout=30, **retry_kwargs)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
items = payload.get("items", [])
|
||||
if not isinstance(items, list):
|
||||
raise ValueError(f"unexpected API response shape from {url}")
|
||||
return items
|
||||
|
||||
|
||||
def post_prefix(post_id: str) -> str:
|
||||
"""Return Rule34Vault media path prefix: 1263322 -> 1263, 738794 -> 738."""
|
||||
return post_id[:-3] if len(post_id) > 3 else post_id
|
||||
|
||||
|
||||
def candidate_image_urls(post_id: str) -> Iterator[str]:
|
||||
prefix = post_prefix(post_id)
|
||||
for ext in IMAGE_EXTS:
|
||||
yield f"{SITE}/posts/{prefix}/{post_id}/{post_id}.{ext}"
|
||||
yield f"{CDN}/posts/{prefix}/{post_id}/{post_id}.{ext}"
|
||||
|
||||
|
||||
def candidate_video_urls(post_id: str) -> Iterator[str]:
|
||||
prefix = post_prefix(post_id)
|
||||
# Observed videos use 480/720 variants; the path prefix is post_id without the last 3 digits.
|
||||
names = [
|
||||
f"{post_id}.480.mp4",
|
||||
f"{post_id}.720.mp4",
|
||||
f"{post_id}.720.hevc.mp4",
|
||||
f"{post_id}.mp4",
|
||||
f"{post_id}.webm",
|
||||
]
|
||||
for name in names:
|
||||
yield f"{CDN}/posts/{prefix}/{post_id}/{name}"
|
||||
yield f"{SITE}/posts/{prefix}/{post_id}/{name}"
|
||||
|
||||
|
||||
def original_from_derivative_url(url: str, post_id: str) -> str:
|
||||
"""Remove display-size suffix from Rule34Vault media filename if present."""
|
||||
parsed = urlparse(url)
|
||||
path = parsed.path
|
||||
suffix_re = "|".join(map(re.escape, DERIVATIVE_SUFFIXES))
|
||||
new_path = re.sub(rf"/{re.escape(post_id)}\.({suffix_re})(\.[A-Za-z0-9]+)$", rf"/{post_id}\2", path)
|
||||
if new_path == path:
|
||||
return url
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return parsed._replace(path=new_path).geturl()
|
||||
return new_path
|
||||
|
||||
|
||||
def _dedupe(items: Iterable[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for item in items:
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
def extract_media_sources_from_html(html: str, post_id: str, page_url: str) -> MediaSources:
|
||||
soup = BeautifulSoup(html, "html.parser")
|
||||
images: list[str] = []
|
||||
image_fallbacks: list[str] = []
|
||||
videos: list[str] = []
|
||||
|
||||
for video in soup.find_all("video"):
|
||||
src = video.get("src")
|
||||
if src:
|
||||
videos.append(urljoin(page_url, src))
|
||||
for source in video.find_all("source"):
|
||||
src = source.get("src")
|
||||
if src:
|
||||
videos.append(urljoin(page_url, src))
|
||||
|
||||
for source in soup.find_all("source"):
|
||||
src = source.get("src")
|
||||
media_type = source.get("type", "")
|
||||
if src and (media_type.startswith("video/") or Path(urlparse(src).path).suffix.lower().lstrip(".") in VIDEO_EXTS):
|
||||
videos.append(urljoin(page_url, src))
|
||||
|
||||
for img in soup.find_all("img"):
|
||||
src = img.get("src")
|
||||
if not src:
|
||||
continue
|
||||
abs_url = urljoin(page_url, src)
|
||||
path = urlparse(abs_url).path
|
||||
if f"/{post_id}/" not in path or not path.lower().endswith(tuple(f".{ext}" for ext in IMAGE_EXTS)):
|
||||
continue
|
||||
original = urljoin(page_url, original_from_derivative_url(abs_url, post_id))
|
||||
if original != abs_url:
|
||||
images.append(original)
|
||||
image_fallbacks.append(abs_url)
|
||||
else:
|
||||
images.append(abs_url)
|
||||
|
||||
for meta_name in ("og:image", "twitter:image"):
|
||||
meta = soup.find("meta", attrs={"property": meta_name}) or soup.find("meta", attrs={"name": meta_name})
|
||||
content = meta.get("content") if meta else None
|
||||
if content:
|
||||
abs_url = urljoin(page_url, content)
|
||||
original = urljoin(page_url, original_from_derivative_url(abs_url, post_id))
|
||||
if original != abs_url:
|
||||
images.append(original)
|
||||
image_fallbacks.append(abs_url)
|
||||
else:
|
||||
images.append(abs_url)
|
||||
|
||||
return MediaSources(images=_dedupe(images), image_fallbacks=_dedupe(image_fallbacks), videos=_dedupe(videos))
|
||||
|
||||
|
||||
def fetch_post_html(session: Session, post_id: str, **retry_kwargs: Any) -> str:
|
||||
url = f"{SITE}/post/{post_id}"
|
||||
response = request_with_retries(session, "GET", url, headers={**HEADERS, "Referer": SITE + "/"}, timeout=30, **retry_kwargs)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
|
||||
def choose_video_source(sources: list[str]) -> str | None:
|
||||
if not sources:
|
||||
return None
|
||||
|
||||
def score(url: str) -> tuple[int, int, int]:
|
||||
lower = url.lower()
|
||||
hevc_penalty = 1 if "hevc" in lower or "hvc1" in lower else 0
|
||||
ext_rank = 0 if lower.endswith(".mp4") else 1
|
||||
# Prefer compatible/non-HEVC; for compatibility, 480 mp4 before heavier variants.
|
||||
resolution_rank = 0 if ".480." in lower else 1 if ".720." in lower else 2
|
||||
return hevc_penalty, ext_rank, resolution_rank
|
||||
|
||||
return sorted(sources, key=score)[0]
|
||||
|
||||
|
||||
def response_extension(url: str, response: Response) -> str:
|
||||
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
|
||||
guessed = mimetypes.guess_extension(content_type) if content_type else None
|
||||
if guessed == ".jpe":
|
||||
guessed = ".jpg"
|
||||
if guessed:
|
||||
return guessed
|
||||
suffix = Path(urlparse(url).path).suffix
|
||||
return suffix or ".bin"
|
||||
|
||||
|
||||
def is_media_response(response: Response, media: str) -> bool:
|
||||
if response.status_code != 200:
|
||||
return False
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
if media == "image":
|
||||
return content_type.startswith("image/")
|
||||
if media == "video":
|
||||
return content_type.startswith("video/") or "mp4" in content_type or "webm" in content_type
|
||||
return content_type.startswith(("image/", "video/"))
|
||||
|
||||
|
||||
def probe_media_url(
|
||||
session: Session,
|
||||
url: str,
|
||||
*,
|
||||
media: str,
|
||||
post_id: str,
|
||||
tries: int,
|
||||
base_retry_delay: float,
|
||||
) -> Response | None:
|
||||
headers = {**HEADERS, "Accept": "*/*", "Referer": f"{SITE}/post/{post_id}"}
|
||||
try:
|
||||
response = request_with_retries(
|
||||
session,
|
||||
"GET",
|
||||
url,
|
||||
headers=headers,
|
||||
stream=True,
|
||||
timeout=45,
|
||||
tries=tries,
|
||||
base_retry_delay=base_retry_delay,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if is_media_response(response, media):
|
||||
return response
|
||||
response.close()
|
||||
return None
|
||||
|
||||
|
||||
def destination_for(out_dir: Path, post_id: str, url: str, response: Response | None = None) -> Path:
|
||||
ext = response_extension(url, response) if response is not None else Path(urlparse(url).path).suffix or ".bin"
|
||||
return out_dir / post_prefix(post_id) / f"{post_id}{ext}"
|
||||
|
||||
|
||||
def stream_download_response(response: Response, dest: Path, chunk_size: int = 1024 * 256) -> int:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
total = 0
|
||||
with tmp.open("wb") as fh:
|
||||
for chunk in response.iter_content(chunk_size=chunk_size):
|
||||
if not chunk:
|
||||
continue
|
||||
fh.write(chunk)
|
||||
total += len(chunk)
|
||||
tmp.replace(dest)
|
||||
return total
|
||||
|
||||
|
||||
def already_downloaded(post_id: str, out_dir: Path) -> Path | None:
|
||||
prefix_dir = out_dir / post_prefix(post_id)
|
||||
if not prefix_dir.exists():
|
||||
return None
|
||||
matches = sorted(path for path in prefix_dir.glob(f"{post_id}.*") if path.suffix != ".part")
|
||||
return matches[0] if matches else None
|
||||
|
||||
|
||||
def append_line(path: Path, line: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(line.rstrip("\n") + "\n")
|
||||
|
||||
|
||||
def append_download_order(manifest: Path, index: int, post_id: str, path: Path, source_url: str) -> None:
|
||||
append_line(manifest, f"{index:06d} {post_id} {path.as_posix()} {source_url}")
|
||||
|
||||
|
||||
def read_seen(path: Path) -> set[str]:
|
||||
if not path.exists():
|
||||
return set()
|
||||
return {line.strip().split()[0] for line in path.read_text(encoding="utf-8").splitlines() if line.strip()}
|
||||
|
||||
|
||||
def resolve_media(
|
||||
session: Session,
|
||||
item: dict[str, Any],
|
||||
*,
|
||||
tries: int,
|
||||
base_retry_delay: float,
|
||||
) -> tuple[str, Response] | None:
|
||||
post_id = str(item["id"])
|
||||
post_type = int(item.get("type", 0))
|
||||
|
||||
if post_type == 0:
|
||||
for url in candidate_image_urls(post_id):
|
||||
response = probe_media_url(session, url, media="image", post_id=post_id, tries=tries, base_retry_delay=base_retry_delay)
|
||||
if response:
|
||||
return url, response
|
||||
|
||||
html = fetch_post_html(session, post_id, tries=tries, base_retry_delay=base_retry_delay)
|
||||
sources = extract_media_sources_from_html(html, post_id, f"{SITE}/post/{post_id}")
|
||||
|
||||
if post_type == 1:
|
||||
ordered_video_sources = []
|
||||
chosen = choose_video_source(sources.videos)
|
||||
if chosen:
|
||||
ordered_video_sources.append(chosen)
|
||||
ordered_video_sources.extend(url for url in sources.videos if url != chosen)
|
||||
ordered_video_sources.extend(candidate_video_urls(post_id))
|
||||
for url in _dedupe(ordered_video_sources):
|
||||
response = probe_media_url(session, url, media="video", post_id=post_id, tries=tries, base_retry_delay=base_retry_delay)
|
||||
if response:
|
||||
return url, response
|
||||
else:
|
||||
for url in _dedupe([*sources.images, *sources.image_fallbacks]):
|
||||
response = probe_media_url(session, url, media="image", post_id=post_id, tries=tries, base_retry_delay=base_retry_delay)
|
||||
if response:
|
||||
return url, response
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def bookmark_items_for_pages(
|
||||
session: Session,
|
||||
username: str,
|
||||
*,
|
||||
start_page: int,
|
||||
end_page: int | None,
|
||||
take: int,
|
||||
tries: int,
|
||||
base_retry_delay: float,
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""Yield bookmark items from public user pages, preserving page/DOM order.
|
||||
|
||||
The direct POST API exists, but observed `page` semantics did not exactly match
|
||||
`/u/<username>?page=N`, so the downloader treats the public page as the
|
||||
source of truth and parses its `script#ng-state` payload for each page.
|
||||
"""
|
||||
del take # kept as CLI/API option for future direct-API use; page HTML is authoritative now.
|
||||
user_id: int | None = None
|
||||
page = start_page
|
||||
while True:
|
||||
if end_page is not None and page > end_page:
|
||||
break
|
||||
html = fetch_user_page(session, username, page, tries=tries, base_retry_delay=base_retry_delay)
|
||||
state = parse_ng_state(html)
|
||||
if user_id is None:
|
||||
user_id = extract_user_id(state, username)
|
||||
items = extract_bookmark_items_from_state(state, user_id)
|
||||
if not items:
|
||||
break
|
||||
for item in items:
|
||||
yield item
|
||||
page += 1
|
||||
|
||||
|
||||
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Download public Rule34Vault bookmarks for a username")
|
||||
parser.add_argument("username", help="Rule34Vault username, e.g. krosh")
|
||||
parser.add_argument("--start-page", type=int, default=1, help="first bookmark page to fetch")
|
||||
parser.add_argument("--end-page", type=int, help="last bookmark page to fetch; omitted means until empty")
|
||||
parser.add_argument("--out", type=Path, default=Path("downloads"), help="output directory")
|
||||
parser.add_argument("--tries", type=int, default=4, help="request attempts for transient failures")
|
||||
parser.add_argument("--delay", type=float, default=0.5, help="sleep between posts")
|
||||
parser.add_argument("--base-retry-delay", type=float, default=1.5, help="initial exponential retry sleep")
|
||||
parser.add_argument("--dry-run", action="store_true", help="list bookmark ids that would be processed; do not download files")
|
||||
parser.add_argument("--limit", type=int, help="maximum number of bookmark items to process after pagination")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = parse_args(argv)
|
||||
out_dir: Path = args.out
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest = out_dir / "download_order.txt"
|
||||
seen_path = out_dir / "seen.txt"
|
||||
failed_path = out_dir / "failed.txt"
|
||||
skipped_path = out_dir / "skipped.txt"
|
||||
seen = read_seen(seen_path)
|
||||
session = make_session()
|
||||
success_index = sum(1 for line in manifest.read_text(encoding="utf-8").splitlines()) if manifest.exists() else 0
|
||||
|
||||
items = bookmark_items_for_pages(
|
||||
session,
|
||||
args.username,
|
||||
start_page=args.start_page,
|
||||
end_page=args.end_page,
|
||||
take=20,
|
||||
tries=args.tries,
|
||||
base_retry_delay=args.base_retry_delay,
|
||||
)
|
||||
|
||||
processed_count = 0
|
||||
for item in tqdm(items, desc="bookmarks", unit="post"):
|
||||
if args.limit is not None and processed_count >= args.limit:
|
||||
break
|
||||
processed_count += 1
|
||||
post_id = str(item.get("id"))
|
||||
if not post_id or post_id == "None":
|
||||
append_line(failed_path, f"unknown invalid bookmark item {item!r}")
|
||||
continue
|
||||
existing = already_downloaded(post_id, out_dir)
|
||||
if post_id in seen or existing:
|
||||
append_line(skipped_path, f"{post_id} already downloaded {existing or ''}".rstrip())
|
||||
continue
|
||||
|
||||
try:
|
||||
if args.dry_run:
|
||||
append_line(skipped_path, f"{post_id} dry-run")
|
||||
continue
|
||||
resolved = resolve_media(
|
||||
session,
|
||||
item,
|
||||
tries=args.tries,
|
||||
base_retry_delay=args.base_retry_delay,
|
||||
)
|
||||
if not resolved:
|
||||
append_line(failed_path, f"{post_id} no media found")
|
||||
continue
|
||||
source_url, response = resolved
|
||||
try:
|
||||
dest = destination_for(out_dir, post_id, source_url, response)
|
||||
size = stream_download_response(response, dest)
|
||||
finally:
|
||||
response.close()
|
||||
success_index += 1
|
||||
append_download_order(manifest, success_index, post_id, dest, source_url)
|
||||
append_line(seen_path, f"{post_id} {dest.as_posix()} {size}")
|
||||
except Exception as exc: # keep batch running and log plain text failure
|
||||
append_line(failed_path, f"{post_id} {type(exc).__name__}: {exc}")
|
||||
time.sleep(args.delay)
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
requests>=2.34
|
||||
beautifulsoup4>=4.15
|
||||
tqdm>=4.68
|
||||
@@ -0,0 +1,85 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
import r34vault_downloader as dl
|
||||
|
||||
|
||||
def test_extract_bookmark_items_from_ng_state_preserves_order():
|
||||
state = {
|
||||
"get:/api/v2/account/user/krosh": {"id": 50311, "userName": "krosh"},
|
||||
"post:/api/v2/post/search/bookmarked/50311": {
|
||||
"items": [
|
||||
{"id": 1075647, "type": 0},
|
||||
{"id": 996379, "type": 1},
|
||||
{"id": 1263322, "type": 0},
|
||||
]
|
||||
},
|
||||
}
|
||||
html = f'<script id="ng-state" type="application/json">{json.dumps(state)}</script>'
|
||||
|
||||
parsed = dl.parse_ng_state(html)
|
||||
user_id = dl.extract_user_id(parsed, "krosh")
|
||||
items = dl.extract_bookmark_items_from_state(parsed, user_id)
|
||||
|
||||
assert user_id == 50311
|
||||
assert [item["id"] for item in items] == [1075647, 996379, 1263322]
|
||||
|
||||
|
||||
def test_candidate_image_urls_prefers_original_rule34vault_then_cdn():
|
||||
assert dl.post_prefix("1263322") == "1263"
|
||||
assert dl.post_prefix("738794") == "738"
|
||||
assert list(dl.candidate_image_urls("1263322"))[:4] == [
|
||||
"https://rule34vault.com/posts/1263/1263322/1263322.jpg",
|
||||
"https://r34xyz.b-cdn.net/posts/1263/1263322/1263322.jpg",
|
||||
"https://rule34vault.com/posts/1263/1263322/1263322.png",
|
||||
"https://r34xyz.b-cdn.net/posts/1263/1263322/1263322.png",
|
||||
]
|
||||
|
||||
|
||||
def test_original_from_derivative_url_removes_small_preview_suffix():
|
||||
assert dl.original_from_derivative_url(
|
||||
"https://rule34vault.com/posts/1263/1263303/1263303.small.jpg", "1263303"
|
||||
) == "https://rule34vault.com/posts/1263/1263303/1263303.jpg"
|
||||
assert dl.original_from_derivative_url(
|
||||
"/posts/1263/1263303/1263303.preview.webp", "1263303"
|
||||
) == "/posts/1263/1263303/1263303.webp"
|
||||
|
||||
|
||||
def test_extract_media_sources_from_post_html_finds_video_and_originalized_images():
|
||||
html = """
|
||||
<html><body>
|
||||
<img class="img" src="/posts/1263/1263303/1263303.small.jpg">
|
||||
<video controls>
|
||||
<source type="video/mp4; codecs=hvc1" src="https://r34xyz.b-cdn.net/posts/996/996379/996379.720.hevc.mp4">
|
||||
<source type="video/mp4" src="https://r34xyz.b-cdn.net/posts/996/996379/996379.480.mp4">
|
||||
</video>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
sources = dl.extract_media_sources_from_html(html, "1263303", "https://rule34vault.com/post/1263303")
|
||||
|
||||
assert "https://rule34vault.com/posts/1263/1263303/1263303.jpg" in sources.images
|
||||
assert "https://rule34vault.com/posts/1263/1263303/1263303.small.jpg" in sources.image_fallbacks
|
||||
assert sources.videos == [
|
||||
"https://r34xyz.b-cdn.net/posts/996/996379/996379.720.hevc.mp4",
|
||||
"https://r34xyz.b-cdn.net/posts/996/996379/996379.480.mp4",
|
||||
]
|
||||
|
||||
|
||||
def test_choose_video_source_prefers_non_hevc_mp4():
|
||||
sources = [
|
||||
"https://r34xyz.b-cdn.net/posts/996/996379/996379.720.hevc.mp4",
|
||||
"https://r34xyz.b-cdn.net/posts/996/996379/996379.480.mp4",
|
||||
]
|
||||
|
||||
assert dl.choose_video_source(sources) == "https://r34xyz.b-cdn.net/posts/996/996379/996379.480.mp4"
|
||||
|
||||
|
||||
def test_append_download_order_writes_plain_text_manifest(tmp_path):
|
||||
manifest = tmp_path / "download_order.txt"
|
||||
|
||||
dl.append_download_order(manifest, 3, "996379", Path("downloads/996/996379.mp4"), "https://example/file.mp4")
|
||||
|
||||
assert manifest.read_text() == "000003 996379 downloads/996/996379.mp4 https://example/file.mp4\n"
|
||||
Reference in New Issue
Block a user