| | #!/usr/bin/env python3 | | | """Download every original-size PNG from https://ai-pose-gallery.pages.dev/ | | | and sort them into folders using the site's own series / category data. | | | No extra packages needed (standard library only). | | | Usage: | | | python download_poses_sorted.py # download + sort + focus folders | | | python download_poses_sorted.py --no-focus # skip the _by_focus folders | | | python download_poses_sorted.py --out D:\poses | | | Re-running skips files that are already downloaded, so you can stop and | | | resume at any time. | | | Result: | | | pose_pngs/ |
| | solo/full_body_standard/<category>/ (365 images) |
| | solo/full_body_sexy/<category>/ (399 images) |
| | solo/upper_body/<category>/ (400 images) |
| | duo/<category>/ (275 images) |
| | _by_focus/<tag>/ (keyword-based extras, see FOCUS below) |
| | catalog.csv (file name -> Japanese pose name, category, angle) |
| | Each pose has 4-5 angle images (front/high/low/side/diag); they sit next to each | | | other in the same folder because they share the pose number in the file name. | | | """ | | | import argparse | | | import csv | | | import json | | | import os | | | import re | | | import shutil | | | import sys | | | import time | | | import urllib.request | | | from collections import Counter | | | from concurrent.futures import ThreadPoolExecutor, as_completed | | | from pathlib import Path |
| | BASE = "https://ai-pose-gallery.pages.dev/" |
| | HEADERS = {"User-Agent": "Mozilla/5.0"} |
| | WORKERS = 6 # parallel downloads; keep modest to be polite to the host |
| | # Site series (系統) -> top-level folder |
| | SERIES = { |
| | "全身1人・標準系": "solo/full_body_standard", | | | "全身1人・セクシー系": "solo/full_body_sexy", | | | "上半身1人": "solo/upper_body", | | | "2人絡み": "duo", | | | } |
| | # Site category (分類) -> folder name |
| | CATEGORY = { |
| | # solo, full body (standard + sexy) | | | "立ち": "standing", | | | "座り": "sitting", | | | "しゃがみ": "crouching", | | | "膝立ち": "kneeling", | | | "膝立ち・四つん這い": "kneeling_all_fours", | | | "寝そべり": "lying", | | | "前かがみ": "leaning_forward", | | | "のけぞり・反り": "arched_back", | | | "ひねり": "twisting", | | | "横座り・女の子座り": "side_sitting", | | | "特殊": "special", | | | # solo, upper body | | | "かわいい系": "cute", | | | "セクシー・露骨寄り系": "sexy_explicit", | | | "胸寄せ・谷間強調系": "chest_emphasis", | | | # duo | | | "立位": "standing", | | | "立位密着": "standing_close", | | | "座位": "seated", | | | "座位密着": "seated_close", | | | "対面座位": "seated_face_to_face", | | | "背面座位": "seated_back_facing", | | | "正常位": "missionary", | | | "後背位": "from_behind", | | | "騎乗位": "on_top", | | | "側位": "side_lying", | | | "密着": "close_embrace", | | | "顔寄せ系": "faces_close", | | | } | | | # Extra keyword-based views, built as hard links (or copies if links fail) in | | | # _by_focus/<tag>/. These match the Japanese POSE NAME, so they are rough: | | | # edit the patterns freely. Only a handful of poses mention the buttocks by name. | | | FOCUS = { | | | "butt": r"お尻|ヒップ|尻", | | | "chest": r"胸|バスト|谷間", | | | "face": r"顔", | | | "legs": r"脚|足|太もも|太腿|開脚", | | | "back": r"背中", | | | } | | | # Whole site categories that also count for a focus tag |
| | FOCUS_CATEGORY = { |
| | "chest": {"胸寄せ・谷間強調系"}, |
| | "face": {"顔寄せ系"}, |
| | } |
| | def get(url): |
| | req = urllib.request.Request(url, headers=HEADERS) |
| | with urllib.request.urlopen(req, timeout=60) as r: |
| | return r.read() |
| | def safe(name): |
| | return re.sub(r'[\\/:*?"<>\|]+', "_", name).strip() or "other" |
| | def fetch_catalog(): |
| | html = get(BASE).decode("utf-8", "replace") |
| | items, seen = [], set() |
| | # The gallery page embeds its catalog as {"id": ..., "系統": ..., "原寸": ...} objects. |
| | for m in re.finditer(r'\{"id":[^{}]*\}', html): |
| | try: | | | o = json.loads(m.group(0)) | | | except ValueError: | | | continue |
| | if o.get("原寸") and o.get("id") not in seen: |
| | seen.add(o["id"]) |
| | items.append(o) |
| | return items |
| | def folder_for(o): |
| | series = SERIES.get(o.get("系統"), safe(o.get("系統", "other"))) |
| | if series == "duo": |
| | cat = CATEGORY.get(o.get("分類"), safe(o.get("分類", "other"))) |
| | return Path("duo") / cat |
| | cat = CATEGORY.get(o.get("分類"), safe(o.get("分類", "other"))) |
| | return Path(*series.split("/")) / cat |
| | def target_path(out, o): |
| | return out / folder_for(o) / Path(o["原寸"]).name |
| | def download(out, o): |
| | dest = target_path(out, o) |
| | if dest.exists() and dest.stat().st_size > 0: |
| | return "skipped" | | | dest.parent.mkdir(parents=True, exist_ok=True) | | | err = None | | | for attempt in range(3): | | | try: |
| | data = get(BASE + o["原寸"]) |
| | tmp = dest.with_suffix(".part") |
| | tmp.write_bytes(data) |
| | tmp.replace(dest) |
| | return "ok" | | | except Exception as e: # network hiccup: wait and retry | | | err = e |
| | time.sleep(1 + attempt) |
| | return f"failed: {err}" |
| | def write_catalog(out, items): |
| | with open(out / "catalog.csv", "w", newline="", encoding="utf-8-sig") as f: |
| | w = csv.writer(f) |
| | w.writerow(["file", "folder", "series_ja", "category_ja", "pose_number", "pose_name_ja", "angle"]) | | | for o in items: |
| | name = Path(o["原寸"]).name |
| | angle = Path(o["原寸"]).stem.rsplit("_", 1)[-1] |
| | w.writerow([name, folder_for(o).as_posix(), o.get("系統", ""), o.get("分類", ""), |
| | o.get("ポーズ番号", ""), o.get("ポーズ名", ""), angle]) |
| | def build_focus(out, items): |
| | counts = Counter() |
| | for o in items: |
| | src = target_path(out, o) |
| | if not src.exists(): |
| | continue |
| | for tag, pattern in FOCUS.items(): |
| | if re.search(pattern, o.get("ポーズ名", "")) or o.get("分類") in FOCUS_CATEGORY.get(tag, ()): |
| | dst_dir = out / "_by_focus" / tag | | | dst_dir.mkdir(parents=True, exist_ok=True) | | | dst = dst_dir / src.name | | | if dst.exists(): | | | continue | | | try: | | | os.link(src, dst) # no extra disk space | | | except OSError: |
| | shutil.copy2(src, dst) |
| | counts[tag] += 1 |
| | return counts | | | def main(): | | | ap = argparse.ArgumentParser(description=doc, formatter_class=argparse.RawDescriptionHelpFormatter) |
| | ap.add_argument("--out", default=str(Path(__file__).resolve().parent / "pose_pngs"), |
| | help="output folder (default: pose_pngs next to this script)") |
| | ap.add_argument("--no-focus", action="store_true", help="do not build the _by_focus folders") |
| | args = ap.parse_args() |
| | out = Path(args.out) |
| | items = fetch_catalog() |
| | if not items: | | | sys.exit("Could not read the image catalog from the page (site layout may have changed).") |
| | out.mkdir(parents=True, exist_ok=True) |
| | print(f"Found {len(items)} images. Saving to {out}") |
| | done = failed = 0 |
| | with ThreadPoolExecutor(max_workers=WORKERS) as pool: |
| | futures = {pool.submit(download, out, o): o for o in items} |
| | for i, fut in enumerate(as_completed(futures), 1): |
| | result = fut.result() |
| | if result.startswith("failed"): |
| | failed += 1 | | | print(f" {futures[fut]['原寸']}: {result}") | | | else: | | | done += 1 |
| | if i % 50 == 0 or i == len(items): |
| | print(f" {i}/{len(items)} processed") |
| | write_catalog(out, items) |
| | if not args.no_focus: | | | counts = build_focus(out, items) | | | if counts: |
| | print("Focus folders: " + ", ".join(f"{k}={v}" for k, v in sorted(counts.items()))) |
| | print(f"Finished: {done} saved/skipped, {failed} failed.") |
| | if failed: | | | print("Run the script again to retry the failed ones.") |
| | if __name__ == "__main__": |
| | main() |