Add recurring CLI scrape automation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Your Name
2026-05-24 17:04:07 -05:00
parent 48b52041a5
commit 43f5fa3b71
16 changed files with 1299 additions and 0 deletions

353
scripts/setup-cron.sh Executable file
View File

@@ -0,0 +1,353 @@
#!/usr/bin/env bash
set -Eeuo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
REPO_ROOT="${DCE_REPO_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd -P)}"
COMPOSE_FILE="${DCE_COMPOSE_FILE:-$REPO_ROOT/docker-compose.yml}"
ENV_FILE="${DCE_ENV_FILE:-$REPO_ROOT/scrape.env}"
CONFIG_FILE="${DCE_CONFIG_FILE:-$REPO_ROOT/config/scrape-targets.json}"
LOG_FILE="${DCE_LOG_FILE:-$REPO_ROOT/logs/discord-scrape.log}"
JOB_NAME="discord-scrape"
INTERVAL="monthly"
RUN_AT="04:00"
CRON_EXPRESSION=""
DRY_RUN=0
REMOVE=0
SKIP_PREFLIGHT=0
TARGETS=()
GUILDS=()
CHANNELS=()
JQ_BIN="${DCE_JQ_BIN:-jq}"
CRONTAB_BIN="${DCE_CRONTAB_BIN:-crontab}"
DOCKER_BIN="${DCE_DOCKER_BIN:-docker}"
COMPOSE_BIN="${DCE_COMPOSE_BIN:-}"
DOCKER_BIN_OVERRIDDEN=0
if [[ -n "${DCE_DOCKER_BIN:-}" ]]; then
DOCKER_BIN_OVERRIDDEN=1
fi
usage() {
cat <<EOF
Usage:
$(basename "$0") [options]
Options:
--target NAME Restrict the cron job to one configured target. Repeatable.
--guild ID Narrow a selected target to one of its allowed guild IDs. Repeatable.
--channel ID Narrow a selected target to one of its allowed channel IDs. Repeatable.
--interval VALUE monthly, weekly, or daily. Default: monthly
--at HH:MM Run time in 24-hour format. Default: 04:00
--cron EXPR Use an explicit five-field cron expression instead of --interval/--at.
--job-name NAME Marker name for the installed cron block. Default: discord-scrape
--log-file PATH Cron log file. Default: $LOG_FILE
--env-file PATH Compose env file. Default: $ENV_FILE
--skip-preflight Install the cron job without running the authenticated container preflight.
--dry-run Print the cron block instead of installing it.
--remove Remove the managed cron block and exit.
--help Show this help text.
Examples:
$(basename "$0")
$(basename "$0") --target discord_dms --interval weekly --at 02:30
$(basename "$0") --target Cline --channel 123456789012345678 --channel 234567890123456789
EOF
}
die() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
require_program() {
local program_path=$1
command -v "$program_path" >/dev/null 2>&1 || die "Required command '$program_path' is missing."
}
cron_from_schedule() {
local interval=$1
local run_at=$2
local hour minute
[[ "$run_at" =~ ^([0-1][0-9]|2[0-3]):([0-5][0-9])$ ]] || die "--at must use HH:MM in 24-hour time."
hour=${BASH_REMATCH[1]}
minute=${BASH_REMATCH[2]}
case "$interval" in
monthly) printf '%s %s 1 * *' "$minute" "$hour" ;;
weekly) printf '%s %s * * 0' "$minute" "$hour" ;;
daily) printf '%s %s * * *' "$minute" "$hour" ;;
*) die "Unsupported --interval '$interval'. Use monthly, weekly, or daily." ;;
esac
}
strip_existing_job() {
local existing_crontab=$1
local begin_marker=$2
local end_marker=$3
awk -v begin="$begin_marker" -v end="$end_marker" '
$0 == begin { skipping = 1; next }
$0 == end { skipping = 0; next }
!skipping { print }
' <<<"$existing_crontab"
}
build_compose_command() {
local subcommand=$1
local -a command_parts
if [[ -n "$COMPOSE_BIN" ]]; then
command_parts=(
"$COMPOSE_BIN"
--env-file "$ENV_FILE"
-f "$COMPOSE_FILE"
run
-T
--rm
discord-scraper
"$subcommand"
)
elif (( DOCKER_BIN_OVERRIDDEN == 0 )) && command -v docker-compose >/dev/null 2>&1; then
command_parts=(
docker-compose
--env-file "$ENV_FILE"
-f "$COMPOSE_FILE"
run
-T
--rm
discord-scraper
"$subcommand"
)
else
command_parts=(
"$DOCKER_BIN"
compose
--env-file "$ENV_FILE"
-f "$COMPOSE_FILE"
run
-T
--rm
discord-scraper
"$subcommand"
)
fi
local target
for target in "${TARGETS[@]}"; do
command_parts+=(--target "$target")
done
local guild_id
for guild_id in "${GUILDS[@]}"; do
command_parts+=(--guild "$guild_id")
done
local channel_id
for channel_id in "${CHANNELS[@]}"; do
command_parts+=(--channel "$channel_id")
done
printf '%q ' "${command_parts[@]}"
}
ensure_target_directories() {
local selected_targets_json archive_root output_dir
archive_root=$("$JQ_BIN" -r '.archive_root // empty' "$CONFIG_FILE")
[[ -n "$archive_root" ]] || die "Config is missing archive_root."
mkdir -p "$archive_root"
selected_targets_json=$("$JQ_BIN" -cn '$ARGS.positional' --args "${TARGETS[@]}")
if (( ${#TARGETS[@]} == 0 )); then
while IFS= read -r output_dir; do
mkdir -p "$output_dir"
done < <("$JQ_BIN" -r '.targets[] | select(.enabled != false) | .output_dir' "$CONFIG_FILE")
return 0
fi
while IFS= read -r output_dir; do
mkdir -p "$output_dir"
done < <(
"$JQ_BIN" -r \
--argjson selected_targets "$selected_targets_json" \
'.targets[]
| select(.name as $name | $selected_targets | index($name))
| .output_dir' \
"$CONFIG_FILE"
)
}
validate_targets() {
(( ${#TARGETS[@]} == 0 )) && return 0
local requested_targets_json resolved_count
requested_targets_json=$("$JQ_BIN" -cn '$ARGS.positional' --args "${TARGETS[@]}")
resolved_count=$(
"$JQ_BIN" -r \
--argjson requested_targets "$requested_targets_json" \
'[.targets[] | select(.name as $name | $requested_targets | index($name))] | length' \
"$CONFIG_FILE"
)
[[ "$resolved_count" == "${#TARGETS[@]}" ]] || die "One or more --target values are missing from $CONFIG_FILE."
}
run_preflight() {
local preflight_command
[[ -f "$ENV_FILE" ]] || die "Missing env file: $ENV_FILE"
preflight_command=$(build_compose_command preflight)
eval "$preflight_command"
}
main() {
while (($#)); do
case "$1" in
--target)
[[ $# -ge 2 ]] || die "Missing value for --target."
TARGETS+=("$2")
shift 2
;;
--guild)
[[ $# -ge 2 ]] || die "Missing value for --guild."
GUILDS+=("$2")
shift 2
;;
--channel)
[[ $# -ge 2 ]] || die "Missing value for --channel."
CHANNELS+=("$2")
shift 2
;;
--interval)
[[ $# -ge 2 ]] || die "Missing value for --interval."
INTERVAL=$2
shift 2
;;
--at)
[[ $# -ge 2 ]] || die "Missing value for --at."
RUN_AT=$2
shift 2
;;
--cron)
[[ $# -ge 2 ]] || die "Missing value for --cron."
CRON_EXPRESSION=$2
shift 2
;;
--job-name)
[[ $# -ge 2 ]] || die "Missing value for --job-name."
JOB_NAME=$2
shift 2
;;
--log-file)
[[ $# -ge 2 ]] || die "Missing value for --log-file."
LOG_FILE=$2
shift 2
;;
--env-file)
[[ $# -ge 2 ]] || die "Missing value for --env-file."
ENV_FILE=$2
shift 2
;;
--skip-preflight)
SKIP_PREFLIGHT=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--remove)
REMOVE=1
shift
;;
--help|-h)
usage
exit 0
;;
*)
die "Unknown option: $1"
;;
esac
done
require_program "$JQ_BIN"
require_program "$CRONTAB_BIN"
if [[ -n "$COMPOSE_BIN" ]]; then
require_program "$COMPOSE_BIN"
elif (( DOCKER_BIN_OVERRIDDEN == 0 )) && command -v docker-compose >/dev/null 2>&1; then
:
else
require_program "$DOCKER_BIN"
fi
[[ -f "$COMPOSE_FILE" ]] || die "Missing compose file: $COMPOSE_FILE"
[[ -f "$CONFIG_FILE" ]] || die "Missing config file: $CONFIG_FILE"
"$JQ_BIN" empty "$CONFIG_FILE" >/dev/null 2>&1 || die "Invalid JSON config: $CONFIG_FILE"
validate_targets
if (( (${#GUILDS[@]} > 0 || ${#CHANNELS[@]} > 0) && ${#TARGETS[@]} != 1 )); then
die "--guild and --channel overrides require exactly one --target."
fi
local cron_line
if [[ -n "$CRON_EXPRESSION" ]]; then
cron_line=$CRON_EXPRESSION
else
cron_line=$(cron_from_schedule "$INTERVAL" "$RUN_AT")
fi
local begin_marker="# BEGIN ${JOB_NAME}"
local end_marker="# END ${JOB_NAME}"
local current_crontab cleaned_crontab compose_command job_line lock_prefix
current_crontab=$("$CRONTAB_BIN" -l 2>/dev/null || true)
cleaned_crontab=$(strip_existing_job "$current_crontab" "$begin_marker" "$end_marker")
if (( REMOVE == 1 )); then
if (( DRY_RUN == 1 )); then
printf '%s\n' "$cleaned_crontab"
exit 0
fi
printf '%s\n' "$cleaned_crontab" | "$CRONTAB_BIN" -
exit 0
fi
mkdir -p "$(dirname "$LOG_FILE")"
ensure_target_directories
if (( SKIP_PREFLIGHT == 0 )); then
run_preflight
fi
compose_command=$(build_compose_command scrape)
if command -v flock >/dev/null 2>&1; then
lock_prefix=$(printf '%q ' "$(command -v flock)" "-n" "/tmp/${JOB_NAME}.lock")
else
lock_prefix=""
fi
job_line="$cron_line cd $(printf '%q' "$REPO_ROOT") && ${lock_prefix}${compose_command}>> $(printf '%q' "$LOG_FILE") 2>&1"
local cron_block
cron_block=$(printf '%s\n%s\n%s\n' "$begin_marker" "$job_line" "$end_marker")
if (( DRY_RUN == 1 )); then
printf '%s\n' "$cron_block"
exit 0
fi
{
if [[ -n "$cleaned_crontab" ]]; then
printf '%s\n\n' "$cleaned_crontab"
fi
printf '%s\n' "$cron_block"
} | "$CRONTAB_BIN" -
}
main "$@"

View File

@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -Eeuo pipefail
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)
TMP_ENV=$(mktemp "${TMPDIR:-/tmp}/dce-container-smoke.XXXXXX.env")
cleanup() {
rm -f "$TMP_ENV"
}
trap cleanup EXIT
cat >"$TMP_ENV" <<EOF
DISCORD_TOKEN=dummy
DCE_UID=$(id -u)
DCE_GID=$(id -g)
TZ=UTC
EOF
cd "$REPO_ROOT"
docker compose --env-file "$TMP_ENV" build
docker compose --env-file "$TMP_ENV" run --rm discord-scraper help >/dev/null
docker compose --env-file "$TMP_ENV" run --rm discord-scraper list-targets >/dev/null
echo "container smoke test passed"

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env bash
set -Eeuo pipefail
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)
FIXTURE_DIR="$REPO_ROOT/scripts/tests/test-fixtures"
TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/dce-run-smoke.XXXXXX")
ARCHIVE_ROOT="$TMP_DIR/archive"
CONFIG_PATH="$TMP_DIR/config.json"
FAKE_CLI="$TMP_DIR/fake-cli.sh"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
cat >"$CONFIG_PATH" <<JSON
{
"archive_root": "$ARCHIVE_ROOT",
"defaults": {
"include_threads": "all",
"include_voice_channels": false
},
"targets": [
{
"name": "demo",
"kind": "guild",
"output_dir": "$ARCHIVE_ROOT/demo",
"channel_ids": ["111"],
"guild_ids": [],
"guild_name_patterns": []
}
]
}
JSON
cat >"$FAKE_CLI" <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
mode=${FAKE_DCE_MODE:?}
fixture_dir=${FAKE_DCE_FIXTURE_DIR:?}
subcommand=${1:?}
shift || true
case "$subcommand" in
export)
output=""
while (($#)); do
case "$1" in
--output)
output=$2
shift 2
;;
--channel|--format|--after)
shift 2
;;
*)
shift
;;
esac
done
case "$mode" in
initial) cp "$fixture_dir/append-existing.json" "$output" ;;
append) cp "$fixture_dir/append-incremental.json" "$output" ;;
wrong-channel) cp "$fixture_dir/wrong-channel.json" "$output" ;;
*) echo "unexpected mode: $mode" >&2; exit 1 ;;
esac
;;
*)
echo "unexpected subcommand: $subcommand" >&2
exit 1
;;
esac
EOF
chmod +x "$FAKE_CLI"
run_wrapper() {
DISCORD_TOKEN=dummy \
DCE_CLI_BIN="$FAKE_CLI" \
DCE_PRIMARY_CONFIG="$CONFIG_PATH" \
DCE_FALLBACK_CONFIG="$CONFIG_PATH" \
FAKE_DCE_FIXTURE_DIR="$FIXTURE_DIR" \
FAKE_DCE_MODE="$1" \
"$REPO_ROOT/scripts/run-discord-scrape.sh" scrape --target demo
}
run_wrapper initial
DEST="$ARCHIVE_ROOT/demo/channels/111.json"
[[ -f "$DEST" ]] || { echo "expected destination archive missing" >&2; exit 1; }
[[ "$(jq -r '.messages | length' "$DEST")" == "2" ]] || { echo "expected initial message count of 2" >&2; exit 1; }
run_wrapper append
[[ "$(jq -r '.messages | length' "$DEST")" == "3" ]] || { echo "expected appended message count of 3" >&2; exit 1; }
[[ "$(jq -r '.messages[-1].id' "$DEST")" == "3" ]] || { echo "expected last message id 3 after append" >&2; exit 1; }
before_checksum=$(sha256sum "$DEST" | awk '{print $1}')
if run_wrapper wrong-channel; then
echo "wrong-channel fixture should have failed" >&2
exit 1
fi
after_checksum=$(sha256sum "$DEST" | awk '{print $1}')
[[ "$before_checksum" == "$after_checksum" ]] || { echo "destination archive changed after failed wrong-channel run" >&2; exit 1; }
echo "run-discord-scrape smoke test passed"

100
scripts/tests/setup-cron-smoke.sh Executable file
View File

@@ -0,0 +1,100 @@
#!/usr/bin/env bash
set -Eeuo pipefail
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)
TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/dce-cron-smoke.XXXXXX")
ARCHIVE_ROOT="$TMP_DIR/archive"
CONFIG_PATH="$TMP_DIR/config.json"
ENV_PATH="$TMP_DIR/scrape.env"
CRONTAB_FILE="$TMP_DIR/crontab.txt"
DOCKER_LOG="$TMP_DIR/docker.log"
FAKE_DOCKER="$TMP_DIR/docker"
FAKE_CRONTAB="$TMP_DIR/crontab"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
cat >"$CONFIG_PATH" <<JSON
{
"archive_root": "$ARCHIVE_ROOT",
"defaults": {
"include_threads": "all",
"include_voice_channels": false
},
"targets": [
{
"name": "demo",
"kind": "guild",
"output_dir": "$ARCHIVE_ROOT/demo",
"channel_ids": ["111"],
"guild_ids": [],
"guild_name_patterns": []
}
]
}
JSON
cat >"$ENV_PATH" <<EOF
DISCORD_TOKEN=dummy
DCE_UID=1000
DCE_GID=1000
TZ=UTC
EOF
cat >"$FAKE_DOCKER" <<EOF
#!/usr/bin/env bash
set -Eeuo pipefail
printf '%s\n' "\$*" >>"$DOCKER_LOG"
exit 0
EOF
chmod +x "$FAKE_DOCKER"
cat >"$FAKE_CRONTAB" <<EOF
#!/usr/bin/env bash
set -Eeuo pipefail
file="$CRONTAB_FILE"
if [[ "\${1:-}" == "-l" ]]; then
[[ -f "\$file" ]] || exit 1
cat "\$file"
elif [[ "\${1:-}" == "-" ]]; then
cat >"\$file"
else
echo "unexpected crontab args: \$*" >&2
exit 1
fi
EOF
chmod +x "$FAKE_CRONTAB"
printf 'MAILTO=test@example.com\n' >"$CRONTAB_FILE"
run_setup() {
DCE_CONFIG_FILE="$CONFIG_PATH" \
DCE_ENV_FILE="$ENV_PATH" \
DCE_CRONTAB_BIN="$FAKE_CRONTAB" \
DCE_DOCKER_BIN="$FAKE_DOCKER" \
DCE_JQ_BIN="$(command -v jq)" \
DCE_REPO_ROOT="$REPO_ROOT" \
DCE_LOG_FILE="$TMP_DIR/logs/discord-scrape.log" \
"$REPO_ROOT/scripts/setup-cron.sh" --target demo "$@"
}
run_setup
grep -q '^MAILTO=test@example.com$' "$CRONTAB_FILE" || { echo "expected unrelated crontab line to remain" >&2; exit 1; }
[[ "$(grep -c '^# BEGIN discord-scrape$' "$CRONTAB_FILE")" == "1" ]] || { echo "expected exactly one managed cron block after install" >&2; exit 1; }
grep -q 'compose --env-file' "$DOCKER_LOG" || { echo "expected docker preflight to run during install" >&2; exit 1; }
run_setup
[[ "$(grep -c '^# BEGIN discord-scrape$' "$CRONTAB_FILE")" == "1" ]] || { echo "expected exactly one managed cron block after reinstall" >&2; exit 1; }
preview_output=$(run_setup --dry-run)
grep -q '^# BEGIN discord-scrape$' <<<"$preview_output" || { echo "expected dry-run preview to contain managed block" >&2; exit 1; }
[[ "$(grep -c '^# BEGIN discord-scrape$' "$CRONTAB_FILE")" == "1" ]] || { echo "dry-run should not alter crontab state" >&2; exit 1; }
run_setup --remove
grep -q '^MAILTO=test@example.com$' "$CRONTAB_FILE" || { echo "expected unrelated crontab line to remain after remove" >&2; exit 1; }
! grep -q '^# BEGIN discord-scrape$' "$CRONTAB_FILE" || { echo "expected managed cron block to be removed" >&2; exit 1; }
echo "setup-cron smoke test passed"

View File

@@ -0,0 +1,22 @@
{
"channel": {
"id": "111"
},
"messages": [
{
"id": "1",
"timestamp": "2026-01-01T00:00:00Z",
"content": "first"
},
{
"id": "2",
"timestamp": "2026-01-02T00:00:00Z",
"content": "second"
}
],
"dateRange": {
"after": null,
"before": null
},
"exportedAt": "2026-01-02T00:00:00Z"
}

View File

@@ -0,0 +1,22 @@
{
"channel": {
"id": "111"
},
"messages": [
{
"id": "2",
"timestamp": "2026-01-02T00:00:00Z",
"content": "second"
},
{
"id": "3",
"timestamp": "2026-01-03T00:00:00Z",
"content": "third"
}
],
"dateRange": {
"after": "2026-01-02T00:00:00Z",
"before": null
},
"exportedAt": "2026-01-03T00:00:00Z"
}

View File

@@ -0,0 +1,17 @@
{
"channel": {
"id": "999"
},
"messages": [
{
"id": "4",
"timestamp": "2026-01-04T00:00:00Z",
"content": "wrong"
}
],
"dateRange": {
"after": "2026-01-03T00:00:00Z",
"before": null
},
"exportedAt": "2026-01-04T00:00:00Z"
}