- Python 98.5%
- Dockerfile 1.3%
- Shell 0.2%
|
All checks were successful
build-docker-image / build (push) Successful in 11m34s
En prod, ~1 150 posts non taggés étaient ignorés par resolve_media faute de domaine géré : redgifs.com sans www, v3.redgifs.com, /ifr/ (~270) et Imgur (~880). Les URLs Redgifs sont normalisées vers www.redgifs.com/watch/<id> ; Imgur est résolu en lien direct i.imgur.com (.gifv → .mp4, albums ignorés). Imgur ne renvoie pas 404 pour un média supprimé mais redirige vers removed.png (200, qu'on aurait téléchargé puis taggé) : on ne suit pas les redirections Imgur et on les traite comme un lien mort. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HPYnXHGUAU7atRhZfZg2uX |
||
|---|---|---|
| .forgejo/workflows | ||
| docs/handoff | ||
| scripts | ||
| src | ||
| worker | ||
| .dockerignore | ||
| .env.example | ||
| .gitignore | ||
| CLAUDE.md | ||
| docker-compose.worker.yml | ||
| docker-compose.yml | ||
| Dockerfile | ||
| Dockerfile.worker | ||
| README.md | ||
| requirements-worker.txt | ||
| requirements.txt | ||
RedCrawl
Un scraper Reddit léger qui récupère les nouveaux posts d'un ou plusieurs subreddits via old.reddit.com, avec authentification par cookie et scrap incrémental.
Fonctionnement
- Se connecte à Reddit en réutilisant un cookie de session (
REDDIT_COOKIES), pas besoin d'app OAuth. - Parcourt le flux
/newd'un subreddit, page par page, avec un délai entre chaque requête. - Filtre les posts en dessous d'un score minimum et ignore les posts stickied / distinguished / AutoModerator.
- Premier scrap d'un subreddit : remonte jusqu'à
HISTORY_MONTHSmois d'historique. - Scraps suivants : incrémental, s'arrête dès qu'il retombe sur le dernier post déjà enregistré (checkpoint).
- Sauvegarde chaque subreddit dans
data/<subreddit>.json.
Installation
python -m venv .venv
source .venv/bin/activate.fish # ou activate selon ton shell
pip install -r requirements.txt
Configuration
Copie .env.example en .env et renseigne ton cookie de session Reddit :
REDDIT_COOKIES=ton_cookie_reddit_session
Le cookie reddit_session se récupère depuis les DevTools du navigateur (onglet Application/Storage → Cookies) une fois connecté sur reddit.com.
.env est chargé automatiquement au démarrage (via python-dotenv) : python src/main.py suffit, pas besoin d'exporter les variables dans le shell.
D'autres paramètres sont surchargeables via .env (ou modifiables directement dans src/config.py) :
| Variable | Rôle | Défaut |
|---|---|---|
MIN_SCORE |
Score minimum pour garder un post | 10 |
HISTORY_MONTHS |
Profondeur d'historique au premier scrap | 3 |
PAGE_LIMIT |
Nombre de posts par page Reddit | 100 |
REQUEST_DELAY_SECONDS |
Délai entre deux requêtes | 2 |
DATA_DIR |
Dossier de sortie des JSON | data |
DOWNLOAD_DIR |
Dossier de sortie des médias téléchargés | data/downloads |
DOWNLOAD_CONCURRENCY |
Téléchargements de fichiers en parallèle (mode download) |
4 |
MAX_CUSTOM_UPLOAD_MB |
Taille max d'un upload custom via l'API (POST /jobs/custom) |
25 |
TAG_ONNX_THREADS |
Threads ONNX Runtime par inférence WD14/AI-check (baisser pour réduire la charge/chaleur CPU) | 1 |
TAG_PAUSE_SECONDS |
Pause entre deux posts taggés (0 désactive) | 4 |
TAG_WD14_MODEL |
Modèle WD14 (ConvNext_v3/ViT_v3 ~20% plus rapides en CPU, précision moindre) |
SwinV2_v3 |
Utilisation
python src/main.py
Le script demande d'abord l'action à effectuer :
Action (scraper / download / tag) : scraper
Subreddit(s) à scraper : hentai, riasgremory
Tape download (ou toute phrase contenant "download" / "télécharger" / "image") pour basculer en téléchargement des médias :
Action (scraper / download / tag) : je veux download les images
Subreddit(s) à télécharger : hentai
Tape tag (ou "annoter") pour le tagging automatique — il ne demande pas de subreddit, il détecte tout seul tous les data/<subreddit>.json présents et les traite un par un :
Action (scraper / download / tag) : tag
🔎 2 subreddit(s) détecté(s) : hentai, riasgremory
Seuls scraper et download se connectent à Reddit (le cookie doit être valide) ; tag travaille entièrement en local sur les fichiers déjà téléchargés.
download et tag sont idempotents par défaut : ils sautent les posts déjà téléchargés / déjà taggés, donc relancer sur un data/<subreddit>.json partiellement traité ne fait que compléter ce qui manque. Ajoute force dans la réponse à l'action (ex. force download) pour retélécharger / retagger même ce qui existe déjà.
Invariant important : rien n'est jamais supprimé. Un post qui disparaît côté source (post/galerie/Redgifs supprimé) ne touche jamais aux fichiers ou tags déjà en local, même avec force — dans ce cas download/tag ne trouvent juste plus rien à traiter pour ce post et laissent l'existant tel quel. C'est voulu : une fois un média téléchargé, il devient la seule copie qui compte si la source disparaît, donc aucun code du projet ne doit jamais appeler de suppression de fichier.
Chaque post enregistré contient : id, name, title, author, score, num_comments, created_utc, permalink, url, is_self, selftext, link_flair_text, et tags une fois passé par le mode tag (voir Tagging automatique).
Téléchargement des médias
Le mode download travaille uniquement à partir des data/<subreddit>.json déjà scrapés — il ne relance jamais de scrap. Si le fichier n'existe pas encore pour le subreddit demandé, il faut d'abord passer par le mode scraper.
Les médias sont téléchargés dans data/downloads/<subreddit>/<post_id>.<ext> (ou <post_id>_01.<ext>, _02.<ext>, ... pour les galeries).
Résolution par domaine :
i.redd.it: téléchargement direct.v.redd.it: appel à l'API Reddit (/comments/<id>.json) pour récupérermedia.reddit_video.fallback_url(piste vidéo seule, Reddit sert l'audio en DASH séparé — pas de fusion ffmpeg). Même fallbackcrosspost_parent_listque pour les galeries.reddit.com/gallery/...: appel à l'API Reddit (/comments/<id>.json) pour lister les images de la galerie viamedia_metadata. Pour un crosspost (le post lui-même n'a pas degallery_data), on retombe surcrosspost_parent_listdéjà présent dans la même réponse.redgifs.com/watch/...: scrap du HTML de la page (JSON-LDcontentUrl) pour récupérer le mp4, pas d'appel à l'API Redgifs.
Les fichiers déjà présents sur le disque sont sautés (reprise idempotente). La résolution des URLs (appels aux API Reddit/Redgifs ci-dessus) reste séquentielle et espacée de REQUEST_DELAY_SECONDS, comme pour le scrap ; le téléchargement des fichiers une fois résolus, lui, est parallélisé sur DOWNLOAD_CONCURRENCY workers.
Chaque fichier est écrit dans un .part puis renommé vers sa destination finale une fois sa taille comparée au Content-Length annoncé — un téléchargement tronqué (connexion coupée en cours de route) n'est donc jamais pris pour un fichier valide, et sera retenté au run suivant puisque le fichier final n'existe pas encore (le .part, lui, n'est jamais supprimé, cohérent avec l'invariant plus haut).
Import d'un post par URL
En plus du scrap par subreddit, python src/main.py post <url> importe un post Reddit unique à partir de son lien — page complète (https://old.reddit.com/r/<sub>/comments/<id>/...), lien court https://redd.it/<id>, ou lien de partage mobile https://www.reddit.com/r/<sub>/s/<id> (une redirection est suivie pour en extraire l'ID réel) — sans avoir à préciser son subreddit : /comments/<id>.json résout le post indépendamment du sous-chemin r/<subreddit> de l'URL fournie, et son subreddit réel (post["subreddit"]) est lu dans la réponse.
python src/main.py post https://www.reddit.com/r/hentai/comments/abc123/some_title/
Le post est ajouté à data/<son_subreddit>.json s'il n'y est pas déjà (sans filtre de score ni de statut modérateur — contrairement au scrap normal, un lien fourni explicitement est toujours importé), puis ses médias sont téléchargés et taggés immédiatement : l'équivalent d'un scraper + download + tag ciblé sur un seul post plutôt que sur tout un subreddit. Ajoute force pour retélécharger le média si déjà présent (le tag, lui, est toujours recalculé).
Utilisable aussi via l'action interactive du CLI (tape post, ou toute phrase contenant "lien"/"url"/"reddit.com"/"redd.it") et via l'API (POST /jobs/post, voir ci-dessous).
Import d'une image custom (hors Reddit)
python src/main.py custom <chemin_fichier> [titre...] ajoute une image ou vidéo locale (.jpg, .jpeg, .png, .gif, .mp4) qui ne vient pas de Reddit, dans un bucket dédié other traité exactement comme un subreddit : data/other.json + data/downloads/other/.
python src/main.py custom ~/Images/dessin.png Un titre optionnel
L'ID de l'entrée est dérivé du hash SHA-256 du contenu (pas un ID Reddit) : réuploader deux fois le même fichier retombe sur le même post_id au lieu de créer un doublon. Le fichier est ensuite immédiatement passé dans le même pipeline de review automatique que tag (WD14 + détection IA), sans étape manuelle supplémentaire. Les entrées custom portent un champ "source": "custom" pour les distinguer des posts scrapés, et les autres champs (author, score, permalink, ...) sont mis à des valeurs neutres puisqu'il n'y a pas de post Reddit source.
Utilisable aussi via l'action interactive du CLI (tape custom ou upload) et via l'API : POST /jobs/custom en multipart/form-data (champs file et title optionnel), soumis à MAX_CUSTOM_UPLOAD_MB (les jobs restent en mémoire tant que l'API tourne, voir la section API HTTP plus bas).
curl -H "X-API-Key: ta_cle" -X POST http://127.0.0.1:8000/jobs/custom \
-F "file=@/chemin/vers/image.png" -F "title=Un titre optionnel"
Une fois importée, l'image custom est listable/filtrable comme n'importe quel subreddit via GET /subreddits/other/posts.
Tagging automatique
Le mode tag annote chaque post avec des tags style Danbooru/R34 (1girl, large_breasts, rating:explicit, ...) à partir des médias déjà présents dans data/downloads/<subreddit>/, et les sauvegarde dans le champ tags de data/<subreddit>.json.
Le tagging utilise WD14 (modèle de tagging anime entraîné sur Danbooru) via la librairie dghs-imgutils, exécuté en local via onnxruntime — pas d'API externe, pas de clé requise. Le modèle (~450 Mo) est téléchargé automatiquement depuis Hugging Face au premier lancement puis mis en cache. Pour les vidéos (Redgifs), une frame du milieu est extraite avec OpenCV puis taguée comme une image.
Optionnel : définir HF_TOKEN dans l'environnement lève les limites de débit du Hugging Face Hub pour ce premier téléchargement (pas requis, le tagging fonctionne sans).
Un lookup par hash MD5 sur Danbooru/Gelbooru/Rule34 a été envisagé mais écarté : Reddit recompresse toutes les images à l'upload, donc leur hash ne correspond quasiment jamais au fichier original de l'artiste, et Gelbooru/Rule34 exigent désormais une clé API.
Les posts déjà taggés (tags non vide) sont sautés à la relance, et la progression est sauvegardée tous les SAVE_EVERY posts (20 par défaut) pour ne pas perdre le travail en cas d'interruption — le tagging de ~1000 posts prend environ 30 minutes sur CPU.
API HTTP
En plus du CLI, RedCrawl expose une API HTTP (FastAPI) qui pilote les mêmes actions scraper/download/tag à distance (avec suivi de statut par job) et permet de consulter/filtrer les posts déjà scrapés et de servir les médias déjà téléchargés.
python src/api.py
Lance uvicorn sur http://127.0.0.1:8000 (doit être exécuté depuis la racine du repo, comme python src/main.py). La doc interactive Swagger est sur /docs.
Définis API_KEY dans .env pour exiger le header X-API-Key sur toutes les requêtes (recommandé dès que l'API est accessible au-delà de localhost, le contenu servi n'est pas destiné à être public). Si API_KEY est vide, l'API n'exige aucune authentification (pratique en dev local uniquement).
curl -H "X-API-Key: ta_cle" -X POST http://127.0.0.1:8000/jobs/tag -d '{"subreddits": ["hentai"]}' -H "Content-Type: application/json"
Endpoints de contrôle (jobs)
Chaque job tourne dans un unique worker en arrière-plan, un seul à la fois (même logique séquentielle et même respect de REQUEST_DELAY_SECONDS que le CLI) : POST répond immédiatement avec un job_id, à suivre via GET /jobs/{job_id}.
| Méthode & route | Rôle |
|---|---|
POST /jobs/scrape |
{"subreddits": [...]} — lance scrape_subreddit pour chaque subreddit |
POST /jobs/backfill |
{"subreddits": [...]} — récupère l'historique ancien via /top?t=all, hors de portée de /new (plafonné à ~1000 posts par Reddit). À lancer explicitement : le scrap incrémental ne le fait jamais seul |
POST /jobs/download |
{"subreddits": [...], "force": false} |
POST /jobs/tag |
{"subreddits": [...] | null, "force": false} — null/absent auto-détecte tous les data/<subreddit>.json, comme le CLI |
POST /jobs/post |
{"url": "https://...", "force": false} — importe un post unique (subreddit détecté automatiquement), le télécharge et le tague |
POST /jobs/custom |
multipart/form-data : file + title optionnel — ajoute une image/vidéo hors Reddit au bucket other, review automatique (WD14 + détection IA) |
GET /jobs |
Liste tous les jobs (id, kind, subreddits, url, filename, status, created_at) |
GET /jobs/{job_id} |
Détail d'un job : status, résultat par subreddit, erreurs éventuelles |
GET/PUT /settings/tagging |
Active/désactive le tagging ({"enabled": true|false}) |
GET/PUT /settings/tagging-mode |
Bascule tagging local/remote ({"mode": "local|remote"}) — voir Tagging distant |
GET /worker/status |
État du tagging distant : mode, file d'attente, dernier passage du worker |
Endpoints de requête (lecture seule, aucun appel réseau Reddit)
| Méthode & route | Rôle |
|---|---|
GET /subreddits |
Liste des subreddits déjà scrapés (nom, nombre de posts, nombre taggés) |
GET /subreddits/{sub}/posts |
Liste paginée et filtrable (tag, min_score, rating, flair, tagged_only, sort=new|top, limit, offset) |
GET /subreddits/{sub}/posts/{post_id} |
Détail d'un post (schéma du JSON + media_urls) |
GET /media/{sub}/{filename} |
Sert le fichier média local correspondant (supporte les requêtes Range, pratique pour la lecture vidéo) |
python src/main.py continue de fonctionner à l'identique — l'API est une couche additive, pas un remplacement du CLI.
Tagging distant (mode remote)
Par défaut le tagging tourne dans le process de l'API (mode local). Si la machine qui héberge
l'API n'a pas la marge thermique/CPU pour du tagging soutenu (voir TAG_ONNX_THREADS /
TAG_PAUSE_SECONDS / TAG_WD14_MODEL plus haut), un worker externe peut venir chercher du
travail à la place : le serveur scrape/télécharge comme d'habitude mais ne tague plus lui-même,
les posts restent en attente (tagged_at IS NULL) jusqu'à ce qu'un worker les récupère, les
tague, et renvoie le résultat. Design complet, contrat d'API et ordre de mise en route :
docs/handoff/handoff-serveur-tagging-distant.md.
# Basculer en mode remote (met en pause les jobs "tag" locaux en cours, aucune perte de travail)
curl -H "X-API-Key: ta_cle" -X PUT http://127.0.0.1:8000/settings/tagging-mode \
-H "Content-Type: application/json" -d '{"mode": "remote"}'
# État du tagging distant (queue, worker en ligne ou non)
curl -H "X-API-Key: ta_cle" http://127.0.0.1:8000/worker/status
Les routes /worker/* (utilisées par le worker, pas par le frontend) exigent une clé dédiée
WORKER_API_KEY (header X-Worker-Key), distincte de API_KEY : le worker n'a ainsi jamais
accès au reste de l'API. Elles répondent 503 tant que WORKER_API_KEY n'est pas défini.
Structure
src/
├── main.py # point d'entrée CLI, choix scraper/download/tag/post/custom + boucle sur les cibles
├── api.py # point d'entrée API HTTP (FastAPI + uvicorn)
├── api_control.py # endpoints de contrôle : déclenchement et suivi des jobs scraper/download/tag/post/custom
├── api_query.py # endpoints de lecture : subreddits, posts (filtrage), médias
├── api_worker.py # endpoints du worker de tagging distant (mode "remote", clé X-Worker-Key)
├── jobs.py # registre de jobs en mémoire + worker séquentiel en arrière-plan
├── settings.py # réglages persistants modifiables sans redémarrage (tagging_enabled, tagging_mode)
├── auth.py # dépendances FastAPI pour l'auth par clé (X-API-Key admin, X-Worker-Key worker)
├── client.py # connexion Reddit par cookie + test de connexion
├── scraper.py # pagination, filtrage, checkpoint incrémental + résolution d'un post par URL
├── downloader.py # résolution des URLs (direct/galerie/vidéo/redgifs) + téléchargement
├── post_import.py # import ponctuel d'un post par URL : ajout + download + tag
├── custom_upload.py # import d'une image/vidéo hors Reddit (bucket "other") : ajout + tag
├── tagger.py # tagging WD14 des médias téléchargés
└── config.py # variables d'environnement et réglages