Gallery Image Relay scrapes images from DCInside and Arcalive (arca.live), then relays them to Discord, Telegram, and an optional ephemeral web gallery. It is designed to run on cloud servers such as Oracle Cloud and Amazon Web Services.
No persistent image storage: gallery images exist only in the web process's volatile RAM. Image bytes, titles, and URLs are never saved to disk or a database; they expire automatically and disappear immediately whenever the web process restarts.
To prevent successful deliveries from being repeated after a crawler restart, the relay stores only minimal deduplication metadata in SQLite: namespaced post IDs, image SHA256 hashes, destination receipt IDs, and delivery timestamps. The ledger defaults to
var/delivery_archive.sqlite3and can be moved withARCHIVE_PATH; it never contains image bytes.
Two crawler types are supported:
- DCInside (
Module/crawler.py+Module/dcbot.py) - waits behind a 20-post moderation window, prefers the original attachment, and relays one image per post to Discord and Telegram. - Arcalive (
Module/arca_crawler.py+Module/arca_bot.py) - waits behind a 10-image-post moderation window, accesses pages through a residential SOCKS proxy when configured, and relays up to four images per post to Discord.
Both crawlers share the same image pipeline (ImageHandler for compression/dedup), delivery layer (MessageSender), and the optional ephemeral web gallery (web_app).
- Scrapes images from DCInside and automatically posts to Discord and Telegram
- Arcalive (arca.live) crawler using
cloudscraper; when hosted on a cloud VM, a home-machine SOCKS proxy handles the Cloudflare managed challenge that flags datacenter IPs (see "Arcalive: Cloudflare bypass" below) - Multi-image extraction, capped at four images per post (Arcalive) vs single-image extraction (DCInside, spam prevention)
- Discord multi-embed delivery for Arcalive (up to 10 images per message)
- Both crawlers share the same ephemeral web gallery, with a source filter (DCInside / Arcalive / per-gallery) in the UI
- Works seamlessly on cloud environments like Oracle Cloud
- Supports multiple image formats (JPG, PNG, GIF) with automatic compression
- Compression runs once and is reused for both platforms when their size limits match
- Automatic re-compress & retry on Discord 413 (file too large) responses
- Fast HTML parsing via
lxml+SoupStrainer(falls back tohtml.parserif lxml is unavailable) - Config-driven gallery management via
galleries.json- no code changes needed to add new galleries - Optional RAM-only ephemeral web gallery - serves collected images in a near real-time, Pinterest-style masonry feed (titles link to the source post) with no persistent image storage
- Installable as a PWA, ships
sitemap.xml/robots.txt/security.txt, with an optional Cloudflare Turnstile bot gate
- Installable as a PWA, ships
- Duplicate image detection via SHA256 hashing
- Stable post-ID deduplication and strict source/CDN URL validation
- Moderation safety windows before newly published posts become eligible for crawling
- Multi-process architecture for concurrent gallery crawling
- Test suite (pytest) and lint (ruff) enforced by GitHub Actions CI
- Python 3.11+ (CI tests against 3.11 and 3.12)
- Discord Bot Token (for Discord bot integration)
- Telegram Bot Token (for Telegram bot integration) - only needed for DCInside galleries; Arcalive galleries are Discord-only
- The Arcalive crawler requires
cloudscraper(installed viarequirements.txt)
-
Clone the repository:
git clone https://github.com/mytrashcan/gallery-image-relay.git dcinsideImageCrawler
-
Navigate to the project directory:
cd dcinsideImageCrawler -
(Optional) Set up a virtual environment:
python -m venv venv source venv/bin/activate # Linux/MacOS
-
Install the required dependencies:
pip install -r requirements.txt -c constraints.txt
-
Create a
.envfile in the project root. Explicit environment variables take precedence; the legacyModule/.envis only a fallback:DISCORD_TOKEN=your_discord_bot_token TELEGRAM_TOKEN=your_telegram_bot_token TELEGRAM_CHANNEL=your_telegram_chat_id # Optional: Discord upload limit in MB (default 10 — the free-tier limit since Sept 2024). # Raise to 50 (boost level 2) or 100 (boost level 3) if your server is boosted. DISCORD_MAX_SIZE_MB=10
python launcher.pyThe launcher manages multiple crawling processes using a circular queue, cycling through galleries defined in galleries.json.
python run_gallery.py <gallery_name>Example:
python run_gallery.py stariload# Run a single arcalive gallery (same runner - dispatched by "type" in galleries.json)
python run_gallery.py arca_bluearchive
# With web gallery
WEB_GALLERY=1 python run_gallery.py arca_genshinEdit galleries.json and add a new entry:
{
"my_dc_gallery": {
"base_url": "https://gall.dcinside.com/mgallery/board/lists/?id=my_gallery_id",
"channel_ids": ["123456789012345678"]
},
"my_arca_gallery": {
"base_url": "https://arca.live/b/myboard",
"channel_ids": ["123456789012345678"],
"type": "arca"
}
}Set "type": "arca" for arca.live galleries. run_gallery.py (and therefore the launcher) auto-detects this and uses the Arcalive crawler instead of the DCInside one.
No code changes required - just restart the launcher.
You can serve the collected images as a live, ephemeral web feed - a Pinterest-style masonry grid at http://<host>:8000/ that updates roughly in real time (the page polls every 5 seconds while visible, backs off to 60 seconds after errors, and updates existing cards). Post titles link back to their original source posts.
Images are stored only in volatile process memory (RAM), never on disk or in a database. The web process owns a bounded in-memory store. Images disappear when they exceed the TTL, item limit, or byte limit, and every image disappears immediately when the web process restarts.
Crawler processes publish bytes to the web process through an authenticated localhost endpoint. WEB_INGEST_TOKEN must be shared through .env; deploy_oci.sh creates it automatically. The web process enforces WEB_FEED_MAX_ITEMS, WEB_MEMORY_MAX_MB, and WEB_IMAGE_MAX_MB before accepting data.
Thumbnails are generated into BytesIO and count toward the same memory budget. Image responses use Cache-Control: no-store so browsers and CDNs are instructed not to retain them.
The systemd units disable core dumps and swap for web and crawler processes; host crash collectors and hibernation require separate operator controls. Copies sent to Discord, Telegram, browsers, or other external systems remain outside this server's control.
⚠️ LimitCORE=0alone does not stop piped core collectors. When/proc/sys/kernel/core_patternpipes crashes to a collector (Ubuntu'sapport, orsystemd-coredump), the kernel ignores theRLIMIT_COREsize limit - so a native crash (e.g. in Pillow's decoders) could still dump the whole store's image bytes to disk under/var/crashor/var/lib/systemd/coredump. On the server, checkcat /proc/sys/kernel/core_pattern; if it pipes to apport runsudo systemctl mask apport.service, and for systemd-coredump setStorage=none+ProcessSizeMax=0in/etc/systemd/coredump.conf.
Run the launcher with WEB_GALLERY=1 so every crawler also writes to the shared gallery, and start one web server alongside it:
# terminal 1 - start the web server first
python run_web_server.py
# terminal 2 - crawlers (all galleries in galleries.json), each feeding the gallery
WEB_GALLERY=1 python launcher.pyOpen http://localhost:8000/ - images from every gallery appear in one feed. This works for both DCInside and Arcalive galleries - the gallery filter in the web UI shows arca_* gallery names for images coming from arcalive sources.
For quick local testing of one gallery, this runs the bot and an embedded web server together:
python run_web_gallery.py <gallery_name>| Path | Description |
|---|---|
/ |
Masonry gallery page |
/feed?limit=N |
JSON feed of recent items (limit 1-200, default 60) |
/images/{id} |
In-memory image response (Cache-Control: no-store) |
/healthz |
Memory usage, freshness, item count, and ingest readiness |
The server binds to
127.0.0.1:8000by default./internal/imagesrequiresWEB_INGEST_TOKEN; never expose that token to browsers or commit it.
A live terminal dashboard shows the web feed and crawler processes at a glance:
python dashboard.py # refreshes every 2s (Ctrl+C to quit)
python dashboard.py --once # print a single frame
python dashboard.py -i 1 # custom refresh interval (seconds)It reads /healthz + /feed from the web server (WEB_PORT, default 8000) and scans running launcher.py / run_gallery.py processes via psutil - no extra wiring needed. Panels: service status (web up/down, feed size, TTL), per-gallery crawler status (PID / memory / uptime), and the most recent images.
Monitoring from another machine (e.g. checking a Mac deployment from your Mac, or an OCI deployment remotely) works for the service-status and feed panels via the public API:
DASH_BASE_URL=https://dcselfie.win python dashboard.pyThe crawler-process panel (PID/memory/uptime) can't be shown this way - psutil only sees processes on the machine it runs on - so it's replaced with an SSH hint. To see crawler status, run ./dcselfie.sh status or python dashboard.py directly on the server (e.g. over SSH).
cloudscraper alone gets Arcalive's older JS challenge, but arca.live also serves a Cloudflare managed challenge to IPs with a datacenter reputation (Oracle Cloud, AWS, etc.) - a real browser is required to solve it, and headless Chromium on a cloud VM tends to get flagged too (tried and reverted; see git history for nodriver/Xvfb attempts). A residential IP (e.g. a home Mac) is not challenged at all.
The fix: route only the Arcalive crawler's requests through a home machine via a reverse SOCKS proxy over SSH, so arca.live sees a residential IP while DCInside and relay deliveries run directly on the cloud VM. Both Arcalive HTML and CDN image requests use the configured proxy; each concurrent image worker reuses its own Session.
# On the home machine (Mac): open a reverse dynamic (SOCKS) forward to the server.
# No destination after the port -> ssh itself acts as the SOCKS proxy and opens
# the real outbound connection *from this machine*.
ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=3 -R 127.0.0.1:1080 ubuntu@<server-ip>The private com.dcselfie.arca-tunnel.plist is not shipped in this repository. Use a local LaunchAgent or autossh supervisor for the command above; never put private hostnames, SSH keys, or proxy credentials in tracked files. See deployment guidance.
On the server, point the crawler at the tunnel and restart:
echo 'ARCA_SOCKS_PROXY=socks5h://localhost:1080' >> .env
sudo systemctl restart dcselfie-launcherVerify the egress IP actually changed: curl -x socks5h://localhost:1080 https://ifconfig.co should print the home machine's IP, not the server's.
On the server, set ClientAliveInterval 30 / ClientAliveCountMax 3 in /etc/ssh/sshd_config (then sudo systemctl reload ssh). Without this, if the home machine's IP changes (Wi-Fi switch, sleep/wake, ISP DHCP renewal) before it can cleanly close the old SSH session, the server can leave the dead session's reverse-forwarded port 1080 open indefinitely - blocking the new tunnel from rebinding it. With ClientAliveInterval set, the server detects and drops dead sessions within ~90s on its own, so a Wi-Fi change never requires manual cleanup.
⚠️ Don't combine-Dwith-Rfor this (e.g.-R 1080:localhost:1080on top of-D 1080) - that routes the "reverse" connection back into the-Dproxy on the same machine, which loops the traffic back out through the server instead of the home machine, silently defeating the whole point.
⚠️ Never hardcodeARCA_SOCKS_PROXY(or any proxy credentials) directly in a git-tracked file like a.serviceunit - this repo is public, and a proxy password committed to a tracked file stays in git history forever even after removal. Keep it in the server's.envonly (gitignored);launcher.pyalready loads it viapython-dotenv, no systemdEnvironment=line needed.
Two common setups:
- On your own machine (e.g. a Mac left running) - run the crawlers + web server locally and expose the domain with a Cloudflare Tunnel (
cloudflared). No port-forwarding, no public IP, automatic HTTPS. Keep the machine awake (caffeinate -s, or an app like Amphetamine). On macOS,./dcselfie.sh installregisters the crawler / web server / tunnel as background launchd services (hidden, auto-restart, start at login);./dcselfie.sh {start|stop|restart|status|logs|dash|uninstall}manages them, and./dcselfie.sh dashopens the terminal monitor on demand. - On a cloud VM (e.g. Oracle Cloud free tier) - run everything as
systemdservices and front it with a reverse proxy (Caddy gives automatic HTTPS). See the systemd example below; bind the web server to127.0.0.1and let the proxy serve the domain on 443. On Oracle Cloud remember to open ports 80/443 in both the VCN security list and the instance's localiptables.
Run the launcher and web server as systemd services so they survive reboots and SSH disconnects. This repo ships the actual unit files used in production (dcselfie.win) as a reference:
dcselfie-launcher.service- crawlers (WEB_GALLERY=1 launcher.py)dcselfie-web.service- web gallery, bound to127.0.0.1:8000(put a reverse proxy like Caddy in front for the public domain + HTTPS)
sudo cp dcselfie-launcher.service dcselfie-web.service /etc/systemd/system/
# edit WorkingDirectory/User inside if your paths differ, then:
sudo systemctl daemon-reload
sudo systemctl enable --now dcselfie-launcher dcselfie-webSubsequent OCI deployments use ./deploy_oci.sh from a clean checkout. It verifies a fast-forward update, stages a separate constrained virtual environment, checks imports/configuration, then stops workers before replacing code. Failed readiness checks restore the previous commit, venv and existing systemd units. Rollback environments are retained in .deploy/; see deployment and recovery before first use.
Both services load the root .env through the same Python configuration loader. The launcher wants the web service and waits for its authenticated ingest endpoint before starting crawlers; a web restart does not stop the launcher. After changing the unit files, run sudo systemctl daemon-reload. For a manual restart, use sudo systemctl restart dcselfie-web && sudo systemctl restart dcselfie-launcher.
Secrets (DISCORD_TOKEN, ARCA_SOCKS_PROXY, etc.) go in the project's .env, never in the unit files - see the warning in "Arcalive: Cloudflare bypass" above.
Notes for small instances (1 GB RAM free tier):
- The launcher has independent DCInside and Arcalive budgets (up to 5 each), so it can run 10 Python processes in total. Lower
MAX_DC_CRAWLERSandMAX_ARCA_CRAWLERSif memory is tight; each process loads discord.py and Pillow. - Make sure
lxmlinstalled successfully (pip show lxml) — it noticeably reduces CPU time per crawl cycle.
<project-root>/
├── launcher.py # Process manager - runs multiple gallery crawlers
├── run_gallery.py # Single gallery runner (DCInside/Arcalive, dispatched by "type")
├── run_web_gallery.py # Single gallery runner + embedded web gallery (FastAPI)
├── run_web_server.py # Standalone web gallery server (for the multi-gallery launcher setup)
├── dashboard.py # Live terminal monitor (web feed + crawler processes)
├── web_app.py # FastAPI app & bounded RAM-only ephemeral feed (TTL/eviction)
├── web_static/ # Gallery page and versioned static assets
├── galleries.json # Gallery configuration (URLs, channel IDs)
├── requirements.txt # Runtime dependencies
├── requirements-dev.txt # Dev dependencies (pytest, ruff)
├── pyproject.toml # pytest & ruff configuration
├── .github/workflows/ci.yml # CI: lint + tests on Python 3.11/3.12
├── dcselfie-launcher.service # systemd unit: crawlers (see "Running on a server")
├── dcselfie-web.service # systemd unit: web gallery
├── dcselfie.sh # macOS launchd management CLI (install/start/stop/status/dash)
├── Module/
│ ├── config.py # Environment variables, headers, logging setup
│ ├── crawler.py # DCInside page scraping
│ ├── dcbot.py # Discord bot client & crawling orchestration
│ ├── arca_crawler.py # Arcalive page scraping
│ ├── arca_bot.py # Arcalive Discord bot client
│ ├── image_handler.py # Image downloading, deduplication, compression
│ ├── message_sender.py # Discord & Telegram message delivery
│ ├── embeds.py # Shared Discord image-embed builder
│ └── lru_cache.py # Shared bounded-cache used for dedup across crawlers
└── tests/ # pytest test suite
| Setting | Location | Default | Description |
|---|---|---|---|
DISCORD_MAX_SIZE_MB |
.env |
10 MB | Discord upload limit (raise for boosted servers) |
MAX_DC_CRAWLERS |
env | 5 (hard max 5) | Independent DCInside crawler concurrency budget |
MAX_ARCA_CRAWLERS |
env | 5 (hard max 5) | Independent Arcalive crawler concurrency budget |
CRAWLER_BATCH_SECONDS |
env | 3600s | Rotation interval when a platform has more galleries than its limit |
REQUEST_TIMEOUT |
Module/config.py |
15s | HTTP request timeout |
| Crawl interval | Module/dcbot.py |
20-40s | Random delay between crawls |
| Arca crawl interval | Module/arca_bot.py |
30-60s | Random delay between arcalive crawls |
WEB_GALLERY |
env | unset | Set to 1 so launcher.py/run_gallery.py crawlers also feed the web gallery |
WEB_HOST |
env | 127.0.0.1 |
Web gallery bind address (set 0.0.0.0 to expose directly, without a reverse proxy) |
WEB_PORT |
env | 8000 | Web gallery port |
WEB_IMAGE_TTL_SECONDS |
env | 10800 (3h) | How long an image stays in memory |
WEB_FEED_MAX_ITEMS |
env | 120 | Maximum in-memory feed items |
WEB_MEMORY_MAX_MB |
env | 256 | Hard cap for original and thumbnail bytes |
WEB_IMAGE_MAX_MB |
env | 12 | Per-image RAM cap; originals within the cap stay untouched |
WEB_INGEST_MAX_MB |
env | 12 | Maximum localhost upload body |
WEB_UPLOAD_QUEUE_MAX_MB |
env | 32 | Per-crawler original-byte cap, including the in-flight web request |
WEB_UPLOAD_QUEUE_SIZE |
env | 20 | Per-crawler bounded background queue for web uploads |
WEB_FRESHNESS_SECONDS |
env | 900 | Age threshold reported by /healthz |
WEB_INGEST_TOKEN |
.env |
required | Shared secret for crawler-to-web ingestion |
WEB_GALLERY_URL |
env | http://127.0.0.1:8000 |
Internal web-gallery origin |
WEB_STATIC_DIR |
env | web_static |
Directory for HTML/CSS/static assets only |
WEB_THUMB_WIDTH |
env | 480 | In-memory card-thumbnail width (0 disables) |
WEB_MAINTENANCE |
env | unset | Set to 1 to force the maintenance page (503). A .maintenance flag file next to the project, or the path set in WEB_MAINTENANCE_FILE, has the same effect; ./dcselfie.sh down / up toggle that same path (no restart needed) |
ARCA_SOCKS_PROXY |
.env (never commit) |
unset | socks5://... proxy the Arcalive crawler routes through - see "Arcalive Cloudflare bypass" below |
ARCA_DOWNLOAD_CONCURRENCY |
env | 2 | Bounded concurrent Arcalive CDN downloads per crawler |
MEDIA_DOWNLOAD_MAX_MB |
env | 15 | Hard streaming limit for each source image download |
MEDIA_MAX_FRAMES |
env | 200 | Maximum frames accepted from an animated source image |
MEDIA_MAX_ANIMATION_PIXELS |
env | 60000000 | Sum of decoded canvas pixels across animation frames |
MEDIA_MAX_PIXELS |
env | 24000000 | Pixel-count limit checked before image processing |
TURNSTILE_SITEKEY / TURNSTILE_SECRET |
.env (never commit) |
unset | Cloudflare Turnstile bot gate for the web gallery; unset disables it entirely |
WEB_ORIGIN_SECRET |
.env (never commit) |
unset | When set, a request carrying cf-connecting-ip must also carry the matching x-origin-secret header or the header is ignored (socket peer IP used instead). Set the same value in a Cloudflare Transform Rule that injects x-origin-secret. For stronger protection, also enable Cloudflare Authenticated Origin Pulls (mTLS) on the zone; unset always uses the socket peer; untrusted proxy headers are ignored |
pip install -r requirements-dev.txt -c constraints.txt
# Run tests
pytest
# Lint and offline import/configuration smoke check
ruff check .
python scripts/check_install.pyCI (GitHub Actions) runs ruff and the test suite on every push to main and on pull requests.
A post/hash is acknowledged only after every configured Discord channel and (for DCInside) Telegram succeeds. Each successful destination/media pair is committed to SQLite immediately after the transport returns; retries and crawler restarts skip those receipts. Web delivery stays best effort: its bounded RAM queue can drop images during prolonged outage and does not control post acknowledgement. Each crawler process remembers in RAM which media the web gallery confirmed (or already held as a recent duplicate) and which are still queued, so retrying a post does not publish the same image again; a failed or dropped web upload stays retryable. Existing post/hash rows remain compatible. Destination receipts added by this release cannot reconstruct failures already acknowledged by older versions.
This is not exactly-once delivery: a remote service can accept a message just before a timeout or process crash, before SQLite can record it. That uncertain interval can produce a duplicate. Never delete the archive to fix a lock or corruption error. Keep SQLite on a local filesystem, back it up with SQLite's backup API, and retain it across deployments. DeliveryArchive.prune(before) is an explicit retention operation; pruning allows old content to be delivered again and is not run automatically.
Crawlers select eligible posts oldest-first within the current first list page. DC skips the newest 20 normal posts; Arcalive skips the newest 10 image posts. These are delay heuristics, not content moderation. A post whose delivery is not acknowledged is never marked delivered: it keeps its successful destination receipts and is retried after an exponential backoff (1 minute doubling to 30 minutes), while newer eligible posts proceed. Deferrals are logged as post delivery deferred warnings with source, gallery and post ID, escalating to errors from the fifth consecutive failure; alert on those. Backoff state is per process and resets on restart. There is no historical pagination/backfill guarantee: busy boards, downtime, posts that keep failing until they leave the first page, and hourly rotation when there are more than five galleries per source can cause missed posts. See the review's recommended bounded catch-up work before relying on lossless capture.
Turnstile, when configured, protects /feed, /images/* and /like/* regardless of proxy headers. Configure both keys. /healthz remains accessible and its fresh field is an activity indicator, not proof that every destination is healthy. The terminal dashboard's recent-feed panel cannot pass Turnstile; health/process panels still work. A matching WEB_ORIGIN_SECRET is required for the application's direct Cloudflare-header trust path. For Cloudflare-proxied DNS through Caddy, use the Caddy proxy example to forward a stable visitor IP only from verified Cloudflare peers; otherwise IP-bound cookies can fail between feed and image requests. See deployment guidance. Use the Cloudflare ingress example to keep /internal/* off the public tunnel, plus edge request/connection limits.
Application image buffers never use temporary files. Root application logging redacts HTTP/SOCKS URLs, configured tokens and transport exception details; post titles and filenames have been removed from crawler logs. Public request access logs and optional honeypot diagnostics are separate metadata. Honeypot events default to a bounded RAM ring (1,000 events, 100/minute); HONEYPOT_LOG_PATH explicitly enables a rotating file (4 MiB plus one backup) containing bounded IP/UA/path metadata. Set OS journal/log rotation and avoid HTTP debug logging. This application cannot erase copies retained by external platforms, browsers, crash collectors, swap, hibernation or infrastructure logs.
Default web capacity counts original and thumbnail byte lengths (256 MiB), not total process RSS. Two active ingest requests and four verification requests are admitted; overload returns 503 and body reads time out after 10 seconds. The TTL sweeper runs every second and clears RAM on shutdown. Source HTML is capped at 4 MiB, media at 15 MiB, and redirects are revalidated before each fetch. Each download chain (attempts, redirects, retry waits and body reads) has a wall-clock deadline of max(30 s, 4 × the request timeout), which is 60 s for the crawlers' 15 s page and image timeouts; a body that trickles in slower than that is cut off rather than held open by per-read timeouts. Provision memory for decoded images, queues and the Python runtime as well as the store. Arcalive concurrency is validated in the range 1–4.
See production review and change rationale and deployment/recovery.
| Command | Description |
|---|---|
!쓰담쓰담 |
Clear the process-local image hash cache; durable receipts remain |
This project is licensed under the GPL License.