```
# src/danbooru.py
import argparse
import os
import time
from logging import getLogger
from pathlib import Path
from queue import Queue
from threading import Thread

import requests
from PIL import Image
from tqdm import tqdm

from configs.path_config import OUTPUT_DIR

logger = getLogger(__name__)


def rate_limited_get(max_per_second: float):
    interval = 1.0 / max_per_second
    last_call = [0.0]

    def get(*args, **kwargs):
        elapsed = time.perf_counter() - last_call[0]
        if elapsed < interval:
            time.sleep(interval - elapsed)
        response = requests.get(*args, **kwargs)
        last_call[0] = time.perf_counter()
        return response

    return get


def download_posts_until(
        tag: str,
        target_count: int,
        save_dir: Path,
        api_username: str,
        api_key: str,
        min_size: int,
        max_size: int,
        max_ratio: float,
        num_workers: int = 4,
        download_order: str = "rank",
):
    WITH_AUTH = bool(api_username and api_key)
    RATE_LIMIT = 4.0 if WITH_AUTH else 1.0
    limited_get = rate_limited_get(max_per_second=RATE_LIMIT)
    target_dir = OUTPUT_DIR / save_dir

    ratings = ["g", "s", "q", "e"]
    logger.info(f"Start download: tag={tag}, target_count={target_count}, save_dir={target_dir}")

    base = target_count // len(ratings)
    rem = target_count % len(ratings)
    quotas = {r: base + (1 if i < rem else 0) for i, r in enumerate(ratings)}
    logger.info(f"Initial quotas per rating: {quotas}")

    IMAGE_QUEUE = Queue()
    total_downloaded = 0
    fallback_log = {}
    pbar = tqdm(total=target_count, desc=f"Downloading '{tag}' images")

    def download_for_rating(rating: str, quota: int, start_page: int = 1):
        downloaded = 0
        page = start_page
        tag_query = f"{tag} -animated rating:{rating} status:active upvotes:>10"
        if WITH_AUTH and download_order == 'score':
            tag_query += " order:score"

        while downloaded < quota:
            params = {"tags": tag_query, "limit": 200, "page": page}
            if WITH_AUTH:
                params.update({"login": api_username, "api_key": api_key})

            resp = limited_get("https://danbooru.donmai.us/posts.json", params=params)
            resp.raise_for_status()
            posts = resp.json()
            if not posts:
                break

            for post in posts:
                if downloaded >= quota:
                    break
                img_path = download_only(post, target_dir, min_size, max_size, max_ratio, limited_get)
                if img_path:
                    save_tags(post, target_dir)
                    IMAGE_QUEUE.put(img_path)
                    downloaded += 1
                    pbar.update(1)
            page += 1
        return downloaded, page

    def download_worker():
        nonlocal total_downloaded
        next_pages = {r: 1 for r in ratings}
        got = {r: 0 for r in ratings}
        for r in ratings:
            got_r, next_pg = download_for_rating(r, quotas[r], start_page=next_pages[r])
            got[r] = got_r
            next_pages[r] = next_pg
            total_downloaded += got_r
        logger.info(f"After initial pass, downloaded: {got}")

        shortage = target_count - total_downloaded
        if shortage > 0:
            logger.info(f"Shortage detected: {shortage} images, performing fallback in order g, s, q, e")
            for r in ratings:
                if shortage <= 0:
                    break
                extra, next_pg = download_for_rating(r, shortage, start_page=next_pages[r])
                got[r] += extra
                next_pages[r] = next_pg
                total_downloaded += extra
                fallback_log[r] = extra
                shortage -= extra
            logger.info(f"Fallback additions: {fallback_log}")

        logger.info(f"Final downloaded per rating: {got}")
        pbar.close()
        for _ in range(num_workers):
            IMAGE_QUEUE.put(None)

    def processing_worker():
        while True:
            image_path = IMAGE_QUEUE.get()
            if image_path is None:
                IMAGE_QUEUE.task_done()
                break
            process_image(image_path, min_size, max_size)
            IMAGE_QUEUE.task_done()

    # スレッド開始
    download_thread = Thread(target=download_worker)
    download_thread.start()
    workers = [Thread(target=processing_worker) for _ in range(num_workers)]
    for w in workers:
        w.start()
    download_thread.join()
    IMAGE_QUEUE.join()
    for w in workers:
        w.join()


def download_only(post, save_dir, min_size, max_size, max_ratio, limited_get):
    post_id = str(post.get("id"))
    image_url = post.get("file_url")
    if not image_url:
        return None

    width = post.get("image_width")
    height = post.get("image_height")
    if not width or not height:
        return None

    aspect = width / height
    short_side = min(width, height)
    if aspect > max_ratio or aspect < 1 / max_ratio or short_side < min_size:
        return None

    ext = image_url.rsplit('.', 1)[-1].split('?')[0]
    save_dir.mkdir(parents=True, exist_ok=True)
    path = save_dir / f"{post_id}.{ext}"
    if path.exists():
        return path

    try:
        data = limited_get(image_url)
        data.raise_for_status()
        with open(path, 'wb') as f:
            f.write(data.content)
        return path
    except Exception as e:
        logger.warning(f"Download failed for {post_id}: {e}")
        return None


def save_tags(post, save_dir):
    post_id = str(post.get("id"))
    tags = post.get("tag_string", "").split()
    txt_path = save_dir / f"{post_id}.txt"

    existing = set()
    if txt_path.exists():
        with open(txt_path, 'r', encoding='utf-8') as f:
            existing = set(f.read().split(', '))

    merged = sorted(existing.union(tags))
    with open(txt_path, 'w', encoding='utf-8') as f:
        f.write(', '.join(merged))


def process_image(image_path, min_size, max_size):
    try:
        with Image.open(image_path) as img:
            w, h = img.size
            if min(w, h) <= min_size or max(w, h) <= max_size:
                return
            if w > h:
                scale = max(min_size / h, max_size / w)
            else:
                scale = max(min_size / w, max_size / h)
            new_size = (int(w * scale), int(h * scale))
            img.resize(new_size, Image.LANCZOS).save(image_path)  # noqa
    except Exception:
        logger.error(f"Processing failed for {image_path.name}", exc_info=True)


def main():
    parser = argparse.ArgumentParser(
        description="Download images from Danbooru by tag and process them according to size/aspect rules."
    )
    parser.add_argument(
        "--tag", "-t",
        nargs="+",
        required=True,
        help="Space-separated search tags (e.g. 'futanari purple_hair huge_breasts')."
    )
    parser.add_argument(
        "--count", "-c",
        type=int, default=20,
        help="Total number of images to download. Evenly divided across ratings then fallback in priority."
    )
    parser.add_argument(
        "--out", "-o",
        type=Path, default=Path("training"),
        help="Output directory for images and tag files. Created if it doesn't exist."
    )
    parser.add_argument(
        "--username", "-u",
        default=os.getenv("API_USERNAME"),
        help="Danbooru username (or set environment variable API_USERNAME)."
    )
    parser.add_argument(
        "--key", "-k",
        default=os.getenv("Danbooru_API"),
        help="Danbooru API key (or set environment variable Danbooru_API)."
    )
    parser.add_argument(
        "--workers", "-w",
        type=int, default=4,
        help="Number of worker threads for image processing."
    )
    parser.add_argument(
        "--min-size",
        type=int, default=1024,
        help="Minimum required short side length of final images (in pixels)."
    )
    parser.add_argument(
        "--max-size",
        type=int, default=1344,
        help="Ideal long side length to aim for when resizing images (in pixels)."
    )
    parser.add_argument(
        "--max-ratio",
        type=float, default=1.7,
        help="Maximum allowed aspect ratio (long side / short side). Images exceeding this ratio are skipped."
    )
    parser.add_argument(
        "--download-order",
        choices=["rank", "date", "score", "favcount"],
        default="rank",
        help="Ordering of posts: 'score' for score-based, `favcount` for number of Favorites, 'date' for upload date."
    )
    args = parser.parse_args()

    tag_str = " ".join(args.tag)
    download_posts_until(
        tag=tag_str,
        target_count=args.count,
        save_dir=args.out,
        api_username=args.username,
        api_key=args.key,
        min_size=args.min_size,
        max_size=args.max_size,
        max_ratio=args.max_ratio,
        num_workers=args.workers,
        download_order=args.download_order,
    )


if __name__ == "__main__":
    main()
```

download_posts_untilは外部から呼ぶことがある。メインスレッドをブロックしないように適切に非同期関数にして。
理由なくコメントや機能を取り除かないで。コード全体を省略せず出力して。