Compare commits
13 commits
fc2930fbb6
...
b3a39699d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3a39699d1 | ||
| e80c9e08df | |||
| 1202804268 | |||
| 1d479d0150 | |||
| 5c0fd78c1f | |||
|
|
0f6523f852 | ||
| bebeb0c8f0 | |||
| 17a6dae3a4 | |||
| 28f5e8079e | |||
| 6ffcd78d35 | |||
| b588278923 | |||
|
|
c6b408e483 | ||
|
|
83be088263 |
16 changed files with 1404 additions and 24 deletions
205
ops/backup-forgejo.sh
Executable file
205
ops/backup-forgejo.sh
Executable file
|
|
@ -0,0 +1,205 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Backup for self-hosted Forgejo (git.gendsgn.ru) — repos + DB, off-box to S3
|
||||||
|
# under its OWN, more-restricted key (#2203).
|
||||||
|
#
|
||||||
|
# Forgejo is deployed OUTSIDE this repo's checkout, per
|
||||||
|
# infra/Forgejo_Migration_BotServer_To_Beget_2026-05-16.md (vault):
|
||||||
|
# - compose dir on the VM: /home/gendesign/forgejo/
|
||||||
|
# - repo data: /home/gendesign/forgejo/data/forgejo/git/repositories/
|
||||||
|
# - DB: NOT a dedicated container — a separate `forgejo` user+database
|
||||||
|
# inside the SAME shared postgres container as the main app
|
||||||
|
# (gendesign-postgres-1). `pg_dump -U forgejo forgejo` via `docker exec`.
|
||||||
|
#
|
||||||
|
# Two artifacts per run:
|
||||||
|
# 1. DB dump — `pg_dump --no-owner --clean --if-exists` (repos/issues/PRs/
|
||||||
|
# users/settings/Actions config — everything except the git data itself).
|
||||||
|
# 2. Repo bundle — a tar.gz of the bare-repo tree under FORGEJO_REPOS_DIR.
|
||||||
|
# This is functionally a "bundle of repositories": bare repos already
|
||||||
|
# ARE just refs+objects, byte-identical to what `git bundle` would
|
||||||
|
# capture, and tar-ing the whole tree in one shot is far more robust
|
||||||
|
# than enumerating individual repos (new repos need zero script changes,
|
||||||
|
# nothing to keep in sync). LFS objects/attachments/avatars under
|
||||||
|
# FORGEJO_DATA_DIR are OUT of scope for this pass — repo code + full DB
|
||||||
|
# (which has LFS pointers, not the blobs) covers the "backup the code"
|
||||||
|
# ask; note this gap in the PR if larger asset backup is wanted later.
|
||||||
|
#
|
||||||
|
# Off-box S3 upload is MANDATORY here, not optional like ops/backup.sh —
|
||||||
|
# the whole point of this script is getting Forgejo's code off the VM it
|
||||||
|
# lives on, so with no S3 creds configured this refuses to run rather than
|
||||||
|
# silently producing a local-only "backup" that doesn't meet that bar.
|
||||||
|
#
|
||||||
|
# The S3 key here is a SEPARATE, narrower-scoped service user than the one
|
||||||
|
# ops/backup.sh and backup-tradein-db.sh use (`gendsgn-backup-writer`, which
|
||||||
|
# can write anywhere in the bucket root) — this one can ONLY PutObject under
|
||||||
|
# s3://gendsgn-backups/forgejo/*, nothing else, so a compromised Forgejo host
|
||||||
|
# can't touch the main/tradein DB backups sitting in the same bucket. See the
|
||||||
|
# PR description for the exact bucket policy JSON (mirrors the existing
|
||||||
|
# gendsgn-backup-writer policy pattern — vault meta/00_credentials.md
|
||||||
|
# "Selectel S3 — бэкап-бакет gendsgn-backups").
|
||||||
|
#
|
||||||
|
# THE KEY DOES NOT EXIST YET (as of #2203) — a human needs to create the
|
||||||
|
# service user + policy in the Selectel panel first. Until then this script
|
||||||
|
# is expected to fail loudly and exit non-zero; that is the correct,
|
||||||
|
# intentional behaviour, not a bug.
|
||||||
|
#
|
||||||
|
# Missed-run detection (#2203): same sentinel/staleness-check pattern as
|
||||||
|
# ops/backup.sh — see ops/check-backup-staleness.sh for the alerting side.
|
||||||
|
#
|
||||||
|
# Usage (cron — `bash <path>`, not a bare path, same "+x can go missing on a
|
||||||
|
# raw deploy reset" reasoning as the other two backup scripts, #71):
|
||||||
|
# 15 4 * * * bash /opt/gendesign/ops/backup-forgejo.sh >> /var/log/gendesign-backup-forgejo.log 2>&1
|
||||||
|
#
|
||||||
|
# Config file (root-owned, chmod 600, NOT in git) — see
|
||||||
|
# ops/gendesign-backup-forgejo.default.example for the full list:
|
||||||
|
# FORGEJO_S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
|
||||||
|
# FORGEJO_S3_BUCKET=gendsgn-backups
|
||||||
|
# FORGEJO_S3_PREFIX=forgejo/
|
||||||
|
# FORGEJO_S3_ACCESS_KEY=...
|
||||||
|
# FORGEJO_S3_SECRET_KEY=...
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./lib-backup.sh
|
||||||
|
source "$SCRIPT_DIR/lib-backup.sh"
|
||||||
|
|
||||||
|
# --- config (env-overridable) ---
|
||||||
|
FORGEJO_DIR="${FORGEJO_DIR:-/home/gendesign/forgejo}"
|
||||||
|
FORGEJO_DATA_DIR="${FORGEJO_DATA_DIR:-${FORGEJO_DIR}/data/forgejo}"
|
||||||
|
FORGEJO_REPOS_DIR="${FORGEJO_REPOS_DIR:-${FORGEJO_DATA_DIR}/git/repositories}"
|
||||||
|
|
||||||
|
PG_CONTAINER="${PG_CONTAINER:-gendesign-postgres-1}"
|
||||||
|
FORGEJO_DB_USER="${FORGEJO_DB_USER:-forgejo}"
|
||||||
|
FORGEJO_DB_NAME="${FORGEJO_DB_NAME:-forgejo}"
|
||||||
|
|
||||||
|
LOCAL_BACKUP_DIR="${LOCAL_BACKUP_DIR:-/opt/gendesign/backups/forgejo}"
|
||||||
|
KEEP="${KEEP:-7}"
|
||||||
|
MIN_DB_DUMP_BYTES="${MIN_DB_DUMP_BYTES:-2048}" # forgejo DB is small (config/issues/PRs, no git blobs)
|
||||||
|
MIN_REPOS_BUNDLE_BYTES="${MIN_REPOS_BUNDLE_BYTES:-10240}"
|
||||||
|
SENTINEL_FILE="${SENTINEL_FILE:-${LOCAL_BACKUP_DIR}/.last_success}"
|
||||||
|
|
||||||
|
FORGEJO_BACKUP_ENV_FILE="${FORGEJO_BACKUP_ENV_FILE:-/etc/default/gendesign-backup-forgejo}"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[[ -f "$FORGEJO_BACKUP_ENV_FILE" ]] && source "$FORGEJO_BACKUP_ENV_FILE"
|
||||||
|
|
||||||
|
FORGEJO_S3_ENDPOINT="${FORGEJO_S3_ENDPOINT:-}"
|
||||||
|
FORGEJO_S3_BUCKET="${FORGEJO_S3_BUCKET:-}"
|
||||||
|
FORGEJO_S3_PREFIX="${FORGEJO_S3_PREFIX:-forgejo/}"
|
||||||
|
FORGEJO_S3_ACCESS_KEY="${FORGEJO_S3_ACCESS_KEY:-}"
|
||||||
|
FORGEJO_S3_SECRET_KEY="${FORGEJO_S3_SECRET_KEY:-}"
|
||||||
|
|
||||||
|
# --- guard 0: S3 creds MUST be configured — this backup's whole point is
|
||||||
|
# off-box storage under a narrow-scoped key. No creds = refuse to run,
|
||||||
|
# loudly, rather than silently producing a local-only "backup" (unlike
|
||||||
|
# ops/backup.sh, where local-only is an accepted fallback). ---
|
||||||
|
if [[ -z "$FORGEJO_S3_ENDPOINT" || -z "$FORGEJO_S3_BUCKET" || -z "$FORGEJO_S3_ACCESS_KEY" || -z "$FORGEJO_S3_SECRET_KEY" ]]; then
|
||||||
|
log "ERROR: Forgejo S3 backup is NOT CONFIGURED."
|
||||||
|
log "ERROR: missing one or more of FORGEJO_S3_ENDPOINT / FORGEJO_S3_BUCKET / FORGEJO_S3_ACCESS_KEY / FORGEJO_S3_SECRET_KEY."
|
||||||
|
log "ERROR: create the narrow-scoped 'gendsgn-backup-forgejo-writer' S3 service user (see PR for the exact bucket policy)"
|
||||||
|
log "ERROR: and fill them into ${FORGEJO_BACKUP_ENV_FILE} (see ops/gendesign-backup-forgejo.default.example)."
|
||||||
|
log "ERROR: refusing to produce a local-only backup — that does not meet this script's off-box requirement."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$FORGEJO_REPOS_DIR" ]]; then
|
||||||
|
log "ERROR: FORGEJO_REPOS_DIR does not exist -> $FORGEJO_REPOS_DIR"
|
||||||
|
log "ERROR: Forgejo's on-disk layout may differ from the documented default — override FORGEJO_REPOS_DIR."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$LOCAL_BACKUP_DIR"
|
||||||
|
ts=$(date -u +'%Y%m%d_%H%M%S')
|
||||||
|
db_out="${LOCAL_BACKUP_DIR}/forgejo-db_${ts}.sql.gz"
|
||||||
|
repos_out="${LOCAL_BACKUP_DIR}/forgejo-repos_${ts}.tar.gz"
|
||||||
|
|
||||||
|
# --- 1. DB dump ---
|
||||||
|
log "Dumping Forgejo DB (${FORGEJO_DB_NAME} as ${FORGEJO_DB_USER}, container ${PG_CONTAINER}) -> ${db_out}"
|
||||||
|
docker exec "$PG_CONTAINER" pg_dump -U "$FORGEJO_DB_USER" -d "$FORGEJO_DB_NAME" --no-owner --clean --if-exists \
|
||||||
|
| gzip -9 > "$db_out"
|
||||||
|
|
||||||
|
if [[ ! -s "$db_out" ]]; then
|
||||||
|
log "ERROR: DB dump is empty -> $db_out — removing, keeping previous good backups." >&2
|
||||||
|
rm -f "$db_out"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
db_bytes=$(wc -c < "$db_out" | tr -d ' ')
|
||||||
|
if (( db_bytes < MIN_DB_DUMP_BYTES )); then
|
||||||
|
log "ERROR: DB dump only ${db_bytes} bytes (< ${MIN_DB_DUMP_BYTES} floor) — likely a failed dump." >&2
|
||||||
|
rm -f "$db_out"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! verify_dump_integrity "$db_out" "-- PostgreSQL database dump complete" "forgejo DB dump"; then
|
||||||
|
log "Removing suspect DB dump, NOT pruning older good backups." >&2
|
||||||
|
rm -f "$db_out"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "DB dump OK: ${db_out} ($(du -h "$db_out" | cut -f1), ${db_bytes} bytes)"
|
||||||
|
|
||||||
|
# --- 2. repo bundle (tar.gz of the bare-repo tree) ---
|
||||||
|
log "Bundling Forgejo repos from ${FORGEJO_REPOS_DIR} -> ${repos_out}"
|
||||||
|
tar -czf "$repos_out" -C "$(dirname "$FORGEJO_REPOS_DIR")" "$(basename "$FORGEJO_REPOS_DIR")"
|
||||||
|
|
||||||
|
if [[ ! -s "$repos_out" ]]; then
|
||||||
|
log "ERROR: repo bundle is empty -> $repos_out — removing." >&2
|
||||||
|
rm -f "$repos_out"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
repos_bytes=$(wc -c < "$repos_out" | tr -d ' ')
|
||||||
|
if (( repos_bytes < MIN_REPOS_BUNDLE_BYTES )); then
|
||||||
|
log "ERROR: repo bundle only ${repos_bytes} bytes (< ${MIN_REPOS_BUNDLE_BYTES} floor) — likely empty/failed FORGEJO_REPOS_DIR." >&2
|
||||||
|
rm -f "$repos_out"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# tar integrity: a bundle truncated mid-write (disk full, killed process)
|
||||||
|
# fails `tar -t` even when the gzip framing looks superficially OK — same
|
||||||
|
# "don't trust non-empty + big-enough alone" discipline as verify_dump_integrity.
|
||||||
|
if ! tar -tzf "$repos_out" >/dev/null 2>&1; then
|
||||||
|
log "ERROR: repo bundle failed tar integrity check -> $repos_out (truncated archive?)" >&2
|
||||||
|
rm -f "$repos_out"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Repo bundle OK: ${repos_out} ($(du -h "$repos_out" | cut -f1), ${repos_bytes} bytes)"
|
||||||
|
|
||||||
|
# --- 3. upload to S3 (mandatory — see guard 0 above) ---
|
||||||
|
for f in "$db_out" "$repos_out"; do
|
||||||
|
key="${FORGEJO_S3_PREFIX%/}/$(basename "$f")"
|
||||||
|
log "Uploading to s3://${FORGEJO_S3_BUCKET}/${key}"
|
||||||
|
# Same AWS_CA_BUNDLE fix as ops/backup.sh / backup-tradein-db.sh — aws-cli
|
||||||
|
# v2's own baked-in CA bundle is missing the root Selectel's cert chains
|
||||||
|
# to; point it at the container's system store instead.
|
||||||
|
docker run --rm \
|
||||||
|
-e AWS_ACCESS_KEY_ID="$FORGEJO_S3_ACCESS_KEY" \
|
||||||
|
-e AWS_SECRET_ACCESS_KEY="$FORGEJO_S3_SECRET_KEY" \
|
||||||
|
-e AWS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt \
|
||||||
|
-v "$LOCAL_BACKUP_DIR":/backup:ro \
|
||||||
|
amazon/aws-cli:latest \
|
||||||
|
--endpoint-url "$FORGEJO_S3_ENDPOINT" \
|
||||||
|
s3 cp --no-progress "/backup/$(basename "$f")" "s3://${FORGEJO_S3_BUCKET}/${key}"
|
||||||
|
done
|
||||||
|
log "S3 upload OK"
|
||||||
|
|
||||||
|
# --- 4. local retention ---
|
||||||
|
# shellcheck disable=SC2012
|
||||||
|
ls -1t "$LOCAL_BACKUP_DIR"/forgejo-db_*.sql.gz 2>/dev/null \
|
||||||
|
| tail -n +"$((KEEP + 1))" \
|
||||||
|
| xargs -r rm -f
|
||||||
|
# shellcheck disable=SC2012
|
||||||
|
ls -1t "$LOCAL_BACKUP_DIR"/forgejo-repos_*.tar.gz 2>/dev/null \
|
||||||
|
| tail -n +"$((KEEP + 1))" \
|
||||||
|
| xargs -r rm -f
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
remaining_db=( "$LOCAL_BACKUP_DIR"/forgejo-db_*.sql.gz )
|
||||||
|
remaining_repos=( "$LOCAL_BACKUP_DIR"/forgejo-repos_*.tar.gz )
|
||||||
|
shopt -u nullglob
|
||||||
|
log "Forgejo backup done. Local copies retained: ${#remaining_db[@]} db + ${#remaining_repos[@]} repo bundles (KEEP=${KEEP})."
|
||||||
|
|
||||||
|
# --- missed-run detection (#2203): mark success ONLY after everything above
|
||||||
|
# (including the mandatory S3 upload) has passed. ---
|
||||||
|
write_sentinel "$SENTINEL_FILE"
|
||||||
|
|
@ -34,9 +34,19 @@
|
||||||
#
|
#
|
||||||
# Restore: see ops/restore.sh (destructive, INTO the live DB).
|
# Restore: see ops/restore.sh (destructive, INTO the live DB).
|
||||||
# Restore drill (safe, throwaway container): see ops/restore-drill.sh.
|
# Restore drill (safe, throwaway container): see ops/restore-drill.sh.
|
||||||
|
#
|
||||||
|
# Missed-run detection (#2203): on a verified-good run this writes a sentinel
|
||||||
|
# file (SENTINEL_FILE) with the UTC timestamp. A SEPARATE cron entry running
|
||||||
|
# ops/check-backup-staleness.sh against that file is what actually alerts if
|
||||||
|
# cron stops firing — see that script's header for the alert channel and
|
||||||
|
# cron line. This script itself does NOT alert; it only ever marks success.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./lib-backup.sh
|
||||||
|
source "$SCRIPT_DIR/lib-backup.sh"
|
||||||
|
|
||||||
# --- config (env-overridable) ---
|
# --- config (env-overridable) ---
|
||||||
COMPOSE_DIR="${COMPOSE_DIR:-/opt/gendesign}"
|
COMPOSE_DIR="${COMPOSE_DIR:-/opt/gendesign}"
|
||||||
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
COMPOSE_FILE="${COMPOSE_FILE:-docker-compose.prod.yml}"
|
||||||
|
|
@ -49,12 +59,11 @@ MIN_DUMP_BYTES="${MIN_DUMP_BYTES:-51200}" # 50 KiB floor; gzip'd schema-only du
|
||||||
# (No equivalent floor for the globals
|
# (No equivalent floor for the globals
|
||||||
# dump — a handful of roles legitimately
|
# dump — a handful of roles legitimately
|
||||||
# gzips to well under this.)
|
# gzips to well under this.)
|
||||||
|
SENTINEL_FILE="${SENTINEL_FILE:-${LOCAL_BACKUP_DIR}/.last_success}"
|
||||||
|
|
||||||
# Optional S3 env. Loaded from /etc/default/gendesign-backup if present.
|
# Optional S3 env. Loaded from /etc/default/gendesign-backup if present.
|
||||||
[[ -f /etc/default/gendesign-backup ]] && source /etc/default/gendesign-backup
|
[[ -f /etc/default/gendesign-backup ]] && source /etc/default/gendesign-backup
|
||||||
|
|
||||||
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
|
||||||
|
|
||||||
# Integrity check beyond "non-empty"/"big enough": a dump truncated mid-write
|
# Integrity check beyond "non-empty"/"big enough": a dump truncated mid-write
|
||||||
# (disk full, OOM-kill, docker exec dropped) can still gzip into a structurally
|
# (disk full, OOM-kill, docker exec dropped) can still gzip into a structurally
|
||||||
# valid, non-tiny .gz — neither `-s` nor MIN_DUMP_BYTES catch that reliably.
|
# valid, non-tiny .gz — neither `-s` nor MIN_DUMP_BYTES catch that reliably.
|
||||||
|
|
@ -198,3 +207,10 @@ remaining=( "$LOCAL_BACKUP_DIR"/gendesign_[0-9]*.sql.gz )
|
||||||
remaining_globals=( "$LOCAL_BACKUP_DIR"/gendesign_globals_*.sql.gz )
|
remaining_globals=( "$LOCAL_BACKUP_DIR"/gendesign_globals_*.sql.gz )
|
||||||
shopt -u nullglob
|
shopt -u nullglob
|
||||||
log "Backup done. Local dumps retained: ${#remaining[@]} data + ${#remaining_globals[@]} globals (KEEP=${KEEP})."
|
log "Backup done. Local dumps retained: ${#remaining[@]} data + ${#remaining_globals[@]} globals (KEEP=${KEEP})."
|
||||||
|
|
||||||
|
# --- missed-run detection (#2203): mark success ONLY here, after every
|
||||||
|
# guard above (dump/globals integrity, retention, and — since this whole
|
||||||
|
# script runs under `set -euo pipefail` — any S3 upload) has passed. See
|
||||||
|
# ops/check-backup-staleness.sh for the piece that actually alerts if
|
||||||
|
# this stops being refreshed. ---
|
||||||
|
write_sentinel "$SENTINEL_FILE"
|
||||||
|
|
|
||||||
84
ops/check-backup-staleness.sh
Executable file
84
ops/check-backup-staleness.sh
Executable file
|
|
@ -0,0 +1,84 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Missed-run detector for the backup scripts (#2203).
|
||||||
|
#
|
||||||
|
# ops/backup.sh, ops/backup-forgejo.sh, and tradein-mvp/deploy/backup-tradein-db.sh
|
||||||
|
# each write a sentinel file (via write_sentinel() in ops/lib-backup.sh) the
|
||||||
|
# instant a run finishes with a VERIFIED-good dump — same file the older two
|
||||||
|
# scripts already treat as "only mark success once every guard passed".
|
||||||
|
# This script checks how old that sentinel is. If cron stops firing (or every
|
||||||
|
# run fails before reaching the sentinel write), the file goes stale and this
|
||||||
|
# script says so loudly instead of the silence that let backups break for
|
||||||
|
# weeks undetected before (#71).
|
||||||
|
#
|
||||||
|
# Alerts only on a STATE TRANSITION (fresh->stale, stale->fresh), same
|
||||||
|
# discipline as ops/uptime-healthcheck.sh, so an hourly cron doesn't spam
|
||||||
|
# Telegram once a backup is already known to be stale.
|
||||||
|
#
|
||||||
|
# Usage (cron — one line per sentinel, run more often than the backup itself
|
||||||
|
# so a stale state is caught promptly; hourly is a reasonable default for a
|
||||||
|
# daily-cron backup with a 26h threshold):
|
||||||
|
# 0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh \
|
||||||
|
# /opt/gendesign/backups/.last_success 26 "gendesign main backup" \
|
||||||
|
# >> /var/log/gendesign-backup-staleness.log 2>&1
|
||||||
|
# 0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh \
|
||||||
|
# /opt/gendesign/backups/tradein/.last_success 26 "tradein backup" \
|
||||||
|
# >> /var/log/gendesign-backup-staleness.log 2>&1
|
||||||
|
# 0 * * * * bash /opt/gendesign/ops/check-backup-staleness.sh \
|
||||||
|
# /opt/gendesign/backups/forgejo/.last_success 26 "forgejo backup" \
|
||||||
|
# >> /var/log/gendesign-backup-staleness.log 2>&1
|
||||||
|
#
|
||||||
|
# Alert channel: reuses the Telegram bot from ops/uptime-healthcheck.sh (see
|
||||||
|
# notify() in ops/lib-backup.sh) — creds in /etc/default/gendesign-backup,
|
||||||
|
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID. Without them, logs only.
|
||||||
|
#
|
||||||
|
# Exit code: 0 = fresh, 1 = stale or sentinel missing (so this can ALSO be
|
||||||
|
# used as a plain healthcheck by anything that just wants the exit code).
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=./lib-backup.sh
|
||||||
|
source "$SCRIPT_DIR/lib-backup.sh"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
echo "Usage: $(basename "$0") <sentinel-file> [max-age-hours=26] [label=backup]" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ $# -ge 1 ]] || usage
|
||||||
|
|
||||||
|
sentinel_file="$1"
|
||||||
|
max_age_hours="${2:-26}"
|
||||||
|
label="${3:-backup}"
|
||||||
|
state_file="${BACKUP_STALENESS_STATE_FILE:-/var/tmp/gendesign-backup-staleness-state}"
|
||||||
|
|
||||||
|
age_hours="$(sentinel_age_hours "$sentinel_file" 2>/dev/null || true)"
|
||||||
|
|
||||||
|
if [[ -z "$age_hours" ]]; then
|
||||||
|
now_status="stale"
|
||||||
|
log "ERROR: sentinel missing/unreadable -> $sentinel_file (${label} has never completed successfully, or its state was lost)."
|
||||||
|
else
|
||||||
|
if (( age_hours >= max_age_hours )); then
|
||||||
|
now_status="stale"
|
||||||
|
log "ERROR: ${label} sentinel is ${age_hours}h old (threshold ${max_age_hours}h) -> $sentinel_file"
|
||||||
|
else
|
||||||
|
now_status="fresh"
|
||||||
|
log "OK: ${label} sentinel is ${age_hours}h old (< ${max_age_hours}h threshold)."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
prev_status="$(backup_prev_status "$state_file" "$label")"
|
||||||
|
|
||||||
|
if [[ "$now_status" == "stale" ]]; then
|
||||||
|
if [[ "$prev_status" != "stale" ]]; then
|
||||||
|
notify "🔴 ${label}: backup sentinel is stale (missing or older than ${max_age_hours}h) -> ${sentinel_file}. Check cron / backup logs on the VM."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [[ "$prev_status" == "stale" ]]; then
|
||||||
|
notify "✅ ${label}: backup sentinel is fresh again -> ${sentinel_file}."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup_set_status "$state_file" "$label" "$now_status"
|
||||||
|
|
||||||
|
[[ "$now_status" == "fresh" ]]
|
||||||
44
ops/gendesign-backup-forgejo.default.example
Normal file
44
ops/gendesign-backup-forgejo.default.example
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
# Environment file for ops/backup-forgejo.sh (Forgejo code + DB backup, #2203).
|
||||||
|
#
|
||||||
|
# Install on the prod VM as a ROOT-OWNED, chmod-600 file that is NOT in git:
|
||||||
|
# sudo cp /opt/gendesign/ops/gendesign-backup-forgejo.default.example /etc/default/gendesign-backup-forgejo
|
||||||
|
# sudo chmod 600 /etc/default/gendesign-backup-forgejo
|
||||||
|
# sudo $EDITOR /etc/default/gendesign-backup-forgejo # fill in real S3 credentials
|
||||||
|
#
|
||||||
|
# DELIBERATELY a separate file from /etc/default/gendesign-backup (used by
|
||||||
|
# ops/backup.sh and tradein-mvp/deploy/backup-tradein-db.sh) — this backup
|
||||||
|
# uses a SEPARATE, narrower-scoped S3 service user that can ONLY PutObject
|
||||||
|
# under s3://gendsgn-backups/forgejo/*, nothing else. The prod-host writer
|
||||||
|
# key used by the other two backups (`gendsgn-backup-writer`) has root-of-
|
||||||
|
# bucket access; that key must NOT be reused here, and this key must NOT be
|
||||||
|
# put in /etc/default/gendesign-backup.
|
||||||
|
#
|
||||||
|
# THE KEY DOES NOT EXIST YET (as of #2203). Until a human creates the
|
||||||
|
# `gendsgn-backup-forgejo-writer` service user + bucket policy in the
|
||||||
|
# Selectel panel (see the #2203 PR description for the exact policy JSON —
|
||||||
|
# PutObject-only on arn:aws:s3:::gendsgn-backups/forgejo/*, explicit Deny on
|
||||||
|
# GetObject/ListBucket/Delete*) and fills in the four FORGEJO_S3_* vars
|
||||||
|
# below, ops/backup-forgejo.sh refuses to run and exits non-zero loudly. That
|
||||||
|
# is expected, not a bug.
|
||||||
|
#
|
||||||
|
# --- S3 off-site upload (Selectel S3-compatible). All four required. ---
|
||||||
|
#FORGEJO_S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
|
||||||
|
#FORGEJO_S3_BUCKET=gendsgn-backups
|
||||||
|
#FORGEJO_S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY_ONCE_CREATED
|
||||||
|
#FORGEJO_S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY_ONCE_CREATED
|
||||||
|
|
||||||
|
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||||
|
#FORGEJO_S3_PREFIX=forgejo/ # key prefix inside the bucket; must match
|
||||||
|
# the policy's Resource path exactly
|
||||||
|
#KEEP=7 # how many recent local copies to retain
|
||||||
|
# (DB dumps and repo bundles are separate series)
|
||||||
|
#MIN_DB_DUMP_BYTES=2048 # sanity floor for the DB dump (small DB —
|
||||||
|
# config/issues/PRs/users, no git blobs)
|
||||||
|
#MIN_REPOS_BUNDLE_BYTES=10240 # sanity floor for the repo tar.gz
|
||||||
|
#LOCAL_BACKUP_DIR=/opt/gendesign/backups/forgejo
|
||||||
|
#FORGEJO_DIR=/home/gendesign/forgejo # Forgejo compose dir on the VM (NOT
|
||||||
|
# part of the gendesign git checkout)
|
||||||
|
#FORGEJO_REPOS_DIR=/home/gendesign/forgejo/data/forgejo/git/repositories
|
||||||
|
#PG_CONTAINER=gendesign-postgres-1 # Forgejo's DB lives in the SAME shared
|
||||||
|
# postgres container as the main app,
|
||||||
|
# as a separate `forgejo` user+database
|
||||||
|
|
@ -21,6 +21,17 @@
|
||||||
#
|
#
|
||||||
# To rehearse a restore from a dump this script produced (safe, throwaway
|
# To rehearse a restore from a dump this script produced (safe, throwaway
|
||||||
# container, never touches prod) see ops/restore-drill.sh.
|
# container, never touches prod) see ops/restore-drill.sh.
|
||||||
|
#
|
||||||
|
# Missed-run alerting (#2203): ops/check-backup-staleness.sh (separate cron
|
||||||
|
# entry, see its header) alerts when a backup's sentinel goes stale. It reads
|
||||||
|
# TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID from THIS file (not from
|
||||||
|
# /etc/default/gendesign-uptime — deliberately a separate config so backup
|
||||||
|
# alerting doesn't depend on the uptime watchdog's env file existing).
|
||||||
|
# SAME variable names as ops/gendesign-uptime.default.example — point both
|
||||||
|
# files at the same bot/chat if you want one Telegram destination for
|
||||||
|
# everything, or use different bots/chats if you'd rather split the noise.
|
||||||
|
# Without these two set, ops/check-backup-staleness.sh still logs, just
|
||||||
|
# doesn't send a Telegram alert.
|
||||||
|
|
||||||
# --- S3 off-site upload (Selectel S3-compatible). All four required to enable. ---
|
# --- S3 off-site upload (Selectel S3-compatible). All four required to enable. ---
|
||||||
#S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
|
#S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
|
||||||
|
|
@ -28,6 +39,10 @@
|
||||||
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
|
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_KEY
|
||||||
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
|
#S3_SECRET_KEY=REPLACE_WITH_REAL_SECRET_KEY
|
||||||
|
|
||||||
|
# --- missed-run alerting (Telegram, shared bot with uptime watchdog) ---
|
||||||
|
#TELEGRAM_BOT_TOKEN=123456789:AA-REPLACE_WITH_REAL_BOT_TOKEN
|
||||||
|
#TELEGRAM_CHAT_ID=123456789
|
||||||
|
|
||||||
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
# --- optional overrides (defaults are sensible; uncomment only to change) ---
|
||||||
#KEEP=7 # how many recent local dumps to retain (applies to both
|
#KEEP=7 # how many recent local dumps to retain (applies to both
|
||||||
# the main dump series and the globals dump series)
|
# the main dump series and the globals dump series)
|
||||||
|
|
|
||||||
123
ops/lib-backup.sh
Executable file
123
ops/lib-backup.sh
Executable file
|
|
@ -0,0 +1,123 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared helpers for ops/backup.sh, ops/backup-forgejo.sh, and
|
||||||
|
# tradein-mvp/deploy/backup-tradein-db.sh (#2203, missed-run detection).
|
||||||
|
#
|
||||||
|
# SOURCED, not executed directly — no shebang execution of its own. Inherits
|
||||||
|
# the caller's `set -euo pipefail`. Keep this dependency-free: bash builtins +
|
||||||
|
# coreutils (date, stat, mkdir, grep, awk, mktemp) + curl (only used by
|
||||||
|
# notify() when Telegram vars are actually set — curl is already a hard
|
||||||
|
# requirement of ops/uptime-healthcheck.sh on the same box, so this adds no
|
||||||
|
# new dependency).
|
||||||
|
#
|
||||||
|
# Load with (script computes its own dir so this works regardless of cron's
|
||||||
|
# CWD or which repo subdir the caller lives in):
|
||||||
|
# SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# source "$SCRIPT_DIR/lib-backup.sh" # caller is in ops/
|
||||||
|
# source "$SCRIPT_DIR/../../ops/lib-backup.sh" # caller is in tradein-mvp/deploy/
|
||||||
|
|
||||||
|
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
||||||
|
|
||||||
|
# --- notify ------------------------------------------------------------
|
||||||
|
# Reuses the SAME Telegram channel/bot as ops/uptime-healthcheck.sh (#75) —
|
||||||
|
# this is NOT a second alerting system, just the same TELEGRAM_BOT_TOKEN /
|
||||||
|
# TELEGRAM_CHAT_ID variable names read from a DIFFERENT env file
|
||||||
|
# (/etc/default/gendesign-backup, not /etc/default/gendesign-uptime) so
|
||||||
|
# backup alerting doesn't depend on the uptime watchdog's config being
|
||||||
|
# present, and vice versa. Point both files at the same bot/chat if you want
|
||||||
|
# one Telegram destination for everything — that's an ops choice, not this
|
||||||
|
# script's concern.
|
||||||
|
#
|
||||||
|
# No-op (logs only) when unset — this is the extension point: to wire a
|
||||||
|
# different channel later, edit ONLY this function; every caller in this repo
|
||||||
|
# goes through notify(), never curl/telegram directly.
|
||||||
|
notify() {
|
||||||
|
local text="$1"
|
||||||
|
local backup_env="${BACKUP_ENV_FILE:-/etc/default/gendesign-backup}"
|
||||||
|
|
||||||
|
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
[[ -f "$backup_env" ]] && source "$backup_env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "${TELEGRAM_BOT_TOKEN:-}" || -z "${TELEGRAM_CHAT_ID:-}" ]]; then
|
||||||
|
log "NOTIFY (telegram disabled — set TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID in ${backup_env}): $text"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl -fsS --max-time 15 \
|
||||||
|
-X POST "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
|
||||||
|
-d "chat_id=${TELEGRAM_CHAT_ID}" \
|
||||||
|
-d "disable_web_page_preview=true" \
|
||||||
|
--data-urlencode "text=${text}" \
|
||||||
|
>/dev/null 2>&1 \
|
||||||
|
|| log "WARN: telegram sendMessage failed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- sentinel (missed-run detection) ------------------------------------
|
||||||
|
# write_sentinel <path> — call ONLY after every guard in the caller has
|
||||||
|
# already passed (mirrors the existing "never mark success before a dump is
|
||||||
|
# verified good" discipline in backup.sh/backup-tradein-db.sh). Content is
|
||||||
|
# just an ISO-8601 UTC timestamp for human debugging; the actual staleness
|
||||||
|
# check below is based on the file's mtime, not on parsing that content.
|
||||||
|
write_sentinel() {
|
||||||
|
local sentinel_file="$1"
|
||||||
|
mkdir -p "$(dirname "$sentinel_file")"
|
||||||
|
date -u +'%Y-%m-%dT%H:%M:%SZ' > "$sentinel_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# sentinel_age_hours <path> — echoes the sentinel's age in whole hours on
|
||||||
|
# stdout. Returns 1 (nothing echoed) if the file doesn't exist or its mtime
|
||||||
|
# can't be read — caller treats that as "stale" (a backup that never
|
||||||
|
# succeeded is exactly the case this exists to catch).
|
||||||
|
sentinel_age_hours() {
|
||||||
|
local sentinel_file="$1" now_epoch sentinel_epoch
|
||||||
|
[[ -f "$sentinel_file" ]] || return 1
|
||||||
|
now_epoch=$(date -u +%s)
|
||||||
|
# GNU stat (prod VM, Debian) first; BSD stat fallback (local/macOS dev).
|
||||||
|
sentinel_epoch=$(stat -c %Y "$sentinel_file" 2>/dev/null || stat -f %m "$sentinel_file" 2>/dev/null) || return 1
|
||||||
|
echo $(( (now_epoch - sentinel_epoch) / 3600 ))
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- transition-tracked alert state --------------------------------------
|
||||||
|
# Same idiom as ops/uptime-healthcheck.sh's prev_status()/set_status(): a
|
||||||
|
# flat "<label> <status>" file, one line per label, so repeated runs alert
|
||||||
|
# only on a STATE TRANSITION (fresh->stale, stale->fresh) instead of every
|
||||||
|
# single run — avoids Telegram spam from an hourly staleness-check cron.
|
||||||
|
# Intentionally a separate, independent implementation (not shared code with
|
||||||
|
# uptime-healthcheck.sh) — that script is out of scope for this change.
|
||||||
|
backup_prev_status() {
|
||||||
|
local state_file="$1" label="$2" v
|
||||||
|
[[ -f "$state_file" ]] || { echo "unknown"; return; }
|
||||||
|
v="$(grep -E "^${label} " "$state_file" 2>/dev/null | tail -1 | awk '{print $2}')"
|
||||||
|
echo "${v:-unknown}"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_set_status() {
|
||||||
|
local state_file="$1" label="$2" status="$3" tmp
|
||||||
|
mkdir -p "$(dirname "$state_file")"
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
if [[ -f "$state_file" ]]; then
|
||||||
|
grep -vE "^${label} " "$state_file" > "$tmp" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "${label} ${status}" >> "$tmp"
|
||||||
|
mv "$tmp" "$state_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- dump integrity (opt-in for NEW scripts only) ------------------------
|
||||||
|
# Identical logic to the verify_dump_integrity() already duplicated in
|
||||||
|
# ops/backup.sh and tradein-mvp/deploy/backup-tradein-db.sh — deliberately
|
||||||
|
# NOT deduped there (those two are proven/hardened; don't touch working
|
||||||
|
# code for a refactor nobody asked for). This copy exists so NEW scripts
|
||||||
|
# (ops/backup-forgejo.sh) get the same guard without a third copy-paste.
|
||||||
|
verify_dump_integrity() {
|
||||||
|
local file="$1" trailer="$2" label="$3"
|
||||||
|
if ! gzip -t "$file" 2>/dev/null; then
|
||||||
|
log "ERROR: ${label} failed gzip integrity check -> $file" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then
|
||||||
|
log "ERROR: ${label} missing trailer '${trailer}' -> $file (truncated dump?)" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
@ -113,7 +113,15 @@ log "Container up, mapped to 127.0.0.1:${host_port} (only reachable while this d
|
||||||
log "Waiting for postgres to accept connections (timeout ${READY_TIMEOUT}s)..."
|
log "Waiting for postgres to accept connections (timeout ${READY_TIMEOUT}s)..."
|
||||||
ready=0
|
ready=0
|
||||||
for _ in $(seq 1 "$READY_TIMEOUT"); do
|
for _ in $(seq 1 "$READY_TIMEOUT"); do
|
||||||
if docker exec "$CONTAINER" pg_isready -U postgres >/dev/null 2>&1; then
|
# -h 127.0.0.1 is load-bearing, NOT cosmetic. The postgres image runs a
|
||||||
|
# TEMPORARY server during initialisation, and that server already answers
|
||||||
|
# "accepting connections" on the unix socket. Probing the socket therefore
|
||||||
|
# goes green mid-init; the restore starts against the temporary server and
|
||||||
|
# dies with "FATAL: terminating connection due to administrator command"
|
||||||
|
# the moment the entrypoint shuts it down to start the real one. The
|
||||||
|
# temporary server does NOT listen on TCP, so a TCP probe only goes green
|
||||||
|
# on the real server. Same reasoning, same fix as ci-tradein.yml:137-141.
|
||||||
|
if docker exec "$CONTAINER" pg_isready -h 127.0.0.1 -U postgres >/dev/null 2>&1; then
|
||||||
ready=1
|
ready=1
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -105,3 +105,7 @@ tests/test_2992_upsert_unchanged_gate.py::test_coalesce_backfill_still_updates
|
||||||
tests/test_2992_upsert_unchanged_gate.py::test_next_day_rescrape_updates_even_if_unchanged
|
tests/test_2992_upsert_unchanged_gate.py::test_next_day_rescrape_updates_even_if_unchanged
|
||||||
tests/test_2992_upsert_unchanged_gate.py::test_skipped_row_still_yields_listing_id_for_downstream
|
tests/test_2992_upsert_unchanged_gate.py::test_skipped_row_still_yields_listing_id_for_downstream
|
||||||
tests/test_2992_upsert_unchanged_gate.py::test_listing_sources_unchanged_rescrape_same_day_does_not_update
|
tests/test_2992_upsert_unchanged_gate.py::test_listing_sources_unchanged_rescrape_same_day_does_not_update
|
||||||
|
# #3036 — house-поля с детальной страницы Авито → houses (fill-only). Live-тест ходит в
|
||||||
|
# настоящую БД (в CI она есть, #2745), локально без TEST_DATABASE_URL пропускается. Строки
|
||||||
|
# t3036-* тест удаляет в finally.
|
||||||
|
tests/test_3036_detail_house_params_to_houses.py::test_live_fill_only_then_keep_then_unlinked_untouched
|
||||||
|
|
|
||||||
|
|
@ -95,3 +95,65 @@ def test_pipeline_counters_carry_dropped_novostroyki() -> None:
|
||||||
from scraper_kit.orchestration.pipeline import AvitoFullLoadCounters
|
from scraper_kit.orchestration.pipeline import AvitoFullLoadCounters
|
||||||
|
|
||||||
assert "dropped_novostroyki" in AvitoFullLoadCounters().to_dict()
|
assert "dropped_novostroyki" in AvitoFullLoadCounters().to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
# ── follow-up (#3033, комментарий владельца 21.08 15:02): якорный и citywide-пути ──────────
|
||||||
|
# Проверено вживую 21.08 через сайдкар: /prodam/vtorichka-ASgB… принимает geoCoords/radius
|
||||||
|
# (60 карточек, все vtorichka, total=9036 против 55 карточек 41/14 у общей выдачи).
|
||||||
|
# Литерал, а не импорт: на main до правки имени SECONDARY_SLUG нет, и импорт ронял бы весь
|
||||||
|
# файл «нет возможности», а не «неверное значение». Slug — из постановки #3033.
|
||||||
|
SECONDARY_SLUG = "vtorichka-ASgBAgICAkSSA8YQ5geMUg"
|
||||||
|
|
||||||
|
|
||||||
|
def _card(item_id: str) -> str:
|
||||||
|
href = f"/ekaterinburg/kvartiry/2k_kvartira_50_m_5_5et_{item_id}"
|
||||||
|
return (
|
||||||
|
f'<div data-marker="item" data-item-id="{item_id}">'
|
||||||
|
f'<a data-marker="item-title" href="{href}">2-к. квартира, 50 м², 5/9 эт.</a>'
|
||||||
|
f'<meta itemprop="price" content="5000000"></div>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _capture(method: str, **kw: object) -> list[str]:
|
||||||
|
s = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http")) # type: ignore[arg-type]
|
||||||
|
urls: list[str] = []
|
||||||
|
|
||||||
|
async def fake_fetch(url: str, page: int) -> str:
|
||||||
|
urls.append(url)
|
||||||
|
return "<html><body>" + _card(f"id{len(urls)}") + "</body></html>"
|
||||||
|
|
||||||
|
s._fetch_serp_html = fake_fetch # type: ignore[method-assign]
|
||||||
|
asyncio.run(getattr(s, method)(**kw))
|
||||||
|
return urls
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_sweep_uses_secondary_slug_by_default() -> None:
|
||||||
|
"""fetch_around по умолчанию — путь вторички, geoCoords/radius сохранены в query."""
|
||||||
|
urls = _capture("fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1)
|
||||||
|
assert urls, "fetch_around не сделал запроса"
|
||||||
|
u = urlparse(urls[0])
|
||||||
|
assert u.path == f"/ekaterinburg/kvartiry/prodam/{SECONDARY_SLUG}", u.path
|
||||||
|
q = parse_qs(u.query)
|
||||||
|
assert q["geoCoords"] == ["56.84,60.6"] and q["radius"] == ["1"] and q["s"] == ["104"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_anchor_sweep_secondary_only_false_keeps_general_path() -> None:
|
||||||
|
"""Контроль: secondary_only=False — прежняя общая выдача."""
|
||||||
|
urls = _capture(
|
||||||
|
"fetch_around", lat=56.84, lon=60.6, radius_m=1000, pages=1, secondary_only=False
|
||||||
|
)
|
||||||
|
assert urlparse(urls[0]).path == "/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ"
|
||||||
|
|
||||||
|
|
||||||
|
def test_citywide_uses_secondary_slug_by_default() -> None:
|
||||||
|
"""fetch_city_wide по умолчанию — путь вторички; secondary_only=False — общая выдача."""
|
||||||
|
urls = _capture("fetch_city_wide", pages=1)
|
||||||
|
assert urlparse(urls[0]).path == f"/ekaterinburg/kvartiry/prodam/{SECONDARY_SLUG}"
|
||||||
|
urls2 = _capture("fetch_city_wide", pages=1, secondary_only=False)
|
||||||
|
assert urlparse(urls2[0]).path == "/ekaterinburg/kvartiry/prodam-ASgBAgICAUSSA8YQ"
|
||||||
|
|
||||||
|
|
||||||
|
def test_newbuilding_builder_untouched() -> None:
|
||||||
|
"""Контроль: путь новостроек не меняется (свой slug, свой sweep)."""
|
||||||
|
s = AvitoScraper(SimpleNamespace(avito_serp_ekb_only=True, scraper_fetch_mode="http")) # type: ignore[arg-type]
|
||||||
|
assert "novostroyka-ASgBAgICAkSSA8YQ5geOUg" in s._build_newbuilding_url(1)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,208 @@
|
||||||
|
"""Детальная страница Авито больше не выбрасывает house-поля (#3036).
|
||||||
|
|
||||||
|
`save_detail_enrichment` парсит has_concierge/closed_yard/total_floors_house/house_type на
|
||||||
|
каждой карточке и до правки писал в listings только house_type/house_url — остальное
|
||||||
|
терялось. На проде при 10 051 обогащённых карточках с привязкой к дому has_concierge был
|
||||||
|
заполнен у 5 домов из 10 131, closed_yard — у 19. Теперь вторым оператором (fill-only,
|
||||||
|
через listings.house_id_fk) пустые поля дома заполняются; заполненные не трогаются.
|
||||||
|
|
||||||
|
Юнит — подменный Session: на main красный по значению (UPDATE houses не исполняется).
|
||||||
|
Live-тест (skipif без БД): дом + листинг, fill, затем «не затирает», затем «без привязки
|
||||||
|
— дом не тронут».
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
|
||||||
|
|
||||||
|
from scraper_kit.providers.avito.detail import (
|
||||||
|
DetailEnrichment,
|
||||||
|
save_detail_enrichment,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _enrichment(**over: Any) -> DetailEnrichment:
|
||||||
|
base: dict[str, Any] = {
|
||||||
|
"item_id": "t3036-1",
|
||||||
|
"source_url": "https://www.avito.ru/ekaterinburg/kvartiry/x_t3036-1",
|
||||||
|
"has_concierge": True,
|
||||||
|
"closed_yard": False,
|
||||||
|
"total_floors_house": 9,
|
||||||
|
"house_type": "panel",
|
||||||
|
}
|
||||||
|
base.update(over)
|
||||||
|
return DetailEnrichment(**base)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeNested:
|
||||||
|
def __enter__(self) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __exit__(self, *exc: object) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSession:
|
||||||
|
"""Пишет все execute(sql, params); rowcount=1 для обоих операторов."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
self.nested = 0
|
||||||
|
self.committed = 0
|
||||||
|
|
||||||
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
self.calls.append((str(stmt), params or {}))
|
||||||
|
return SimpleNamespace(rowcount=1)
|
||||||
|
|
||||||
|
def begin_nested(self) -> _FakeNested:
|
||||||
|
self.nested += 1
|
||||||
|
return _FakeNested()
|
||||||
|
|
||||||
|
def commit(self) -> None:
|
||||||
|
self.committed += 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_house_params_written_fill_only_via_house_id_fk() -> None:
|
||||||
|
"""Головной: за UPDATE listings идёт UPDATE houses … FROM listings … house_id_fk, fill-only."""
|
||||||
|
db = _FakeSession()
|
||||||
|
assert save_detail_enrichment(db, _enrichment()) is True # type: ignore[arg-type]
|
||||||
|
assert len(db.calls) == 2, [c[0][:40] for c in db.calls]
|
||||||
|
sql, params = db.calls[1]
|
||||||
|
assert "UPDATE houses" in sql and "l.house_id_fk = h.id" in sql
|
||||||
|
for col in ("has_concierge", "closed_yard", "total_floors", "house_type"):
|
||||||
|
assert f"COALESCE(h.{col}," in sql, f"{col} не fill-only"
|
||||||
|
assert params["has_concierge"] is True and params["closed_yard"] is False
|
||||||
|
assert params["total_floors_house"] == 9 and params["house_type"] == "panel"
|
||||||
|
assert params["item_id"] == "t3036-1"
|
||||||
|
assert db.nested == 1, "UPDATE houses обязан идти под SAVEPOINT"
|
||||||
|
assert db.committed == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_lifts_are_not_written_no_such_columns() -> None:
|
||||||
|
"""Контроль: лифты не пишем — колонок в houses нет (conflict_resolution их объявляет зря)."""
|
||||||
|
db = _FakeSession()
|
||||||
|
save_detail_enrichment(db, _enrichment(passenger_elevators=2, cargo_elevators=1)) # type: ignore[arg-type]
|
||||||
|
sql = db.calls[1][0]
|
||||||
|
assert "elevator" not in sql and "lifts" not in sql
|
||||||
|
|
||||||
|
|
||||||
|
def test_nothing_parsed_means_no_second_statement() -> None:
|
||||||
|
"""Контроль: если с карточки house-полей не пришло — второй оператор не исполняется."""
|
||||||
|
db = _FakeSession()
|
||||||
|
save_detail_enrichment( # type: ignore[arg-type]
|
||||||
|
db,
|
||||||
|
_enrichment(has_concierge=None, closed_yard=None, total_floors_house=None, house_type=None),
|
||||||
|
)
|
||||||
|
assert len(db.calls) == 1 and db.nested == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_listing_not_found_skips_houses() -> None:
|
||||||
|
"""Контроль: листинга нет (rowcount 0) — дом не трогаем, False наружу как раньше."""
|
||||||
|
|
||||||
|
class _NotFound(_FakeSession):
|
||||||
|
def execute(self, stmt: Any, params: dict[str, Any] | None = None) -> Any:
|
||||||
|
self.calls.append((str(stmt), params or {}))
|
||||||
|
return SimpleNamespace(rowcount=0)
|
||||||
|
|
||||||
|
db = _NotFound()
|
||||||
|
assert save_detail_enrichment(db, _enrichment()) is False # type: ignore[arg-type]
|
||||||
|
assert len(db.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ── live DB ──────────────────────────────────────────────────────────────────
|
||||||
|
def _live_session() -> Any | None:
|
||||||
|
try:
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
dsn = os.environ.get("TEST_DATABASE_URL") or os.environ.get("DATABASE_URL", "")
|
||||||
|
if not dsn or "localhost:5432/test" in dsn:
|
||||||
|
return None
|
||||||
|
engine = create_engine(dsn, future=True)
|
||||||
|
conn = engine.connect()
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
conn.close()
|
||||||
|
return sessionmaker(bind=engine, future=True)()
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup(db: Any) -> None:
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
db.execute(text("DELETE FROM listings WHERE source='avito' AND source_id LIKE 't3036-%'"))
|
||||||
|
db.execute(text("DELETE FROM houses WHERE source='test' AND ext_house_id LIKE 't3036-%'"))
|
||||||
|
db.commit()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(_live_session() is None, reason="no reachable Postgres test DB")
|
||||||
|
def test_live_fill_only_then_keep_then_unlinked_untouched() -> None:
|
||||||
|
db = _live_session()
|
||||||
|
assert db is not None
|
||||||
|
try:
|
||||||
|
_cleanup_inline = text(
|
||||||
|
"DELETE FROM listings WHERE source='avito' AND source_id LIKE 't3036-%'"
|
||||||
|
)
|
||||||
|
db.execute(_cleanup_inline)
|
||||||
|
db.execute(text("DELETE FROM houses WHERE source='test' AND ext_house_id LIKE 't3036-%'"))
|
||||||
|
hid = db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO houses (source, ext_house_id, url, address) "
|
||||||
|
"VALUES ('test', 't3036-h1', 'https://example.test/houses/t3036-h1', "
|
||||||
|
"'ул. Тестовая, 1') RETURNING id"
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO listings "
|
||||||
|
"(source, source_id, source_url, dedup_hash, address, price_rub, "
|
||||||
|
" house_id_fk, is_active) "
|
||||||
|
"VALUES ('avito', 't3036-1', 'https://www.avito.ru/x/t3036-1', 't3036-dh-1', "
|
||||||
|
" 'ул. Тестовая, 1', 5000000, :hid, true), "
|
||||||
|
" ('avito', 't3036-2', 'https://www.avito.ru/x/t3036-2', 't3036-dh-2', "
|
||||||
|
" 'ул. Тестовая, 2', 5000000, NULL, true)"
|
||||||
|
),
|
||||||
|
{"hid": hid},
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
# 1) fill
|
||||||
|
assert save_detail_enrichment(db, _enrichment()) is True
|
||||||
|
row = db.execute(
|
||||||
|
text(
|
||||||
|
"SELECT has_concierge, closed_yard, total_floors, house_type "
|
||||||
|
"FROM houses WHERE id=:h"
|
||||||
|
),
|
||||||
|
{"h": hid},
|
||||||
|
).one()
|
||||||
|
assert tuple(row) == (True, False, 9, "panel"), tuple(row)
|
||||||
|
# 2) fill-only: другое значение с другой карточки того же дома не затирает
|
||||||
|
assert (
|
||||||
|
save_detail_enrichment(db, _enrichment(has_concierge=False, total_floors_house=16))
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
row = db.execute(
|
||||||
|
text("SELECT has_concierge, total_floors FROM houses WHERE id=:h"), {"h": hid}
|
||||||
|
).one()
|
||||||
|
assert tuple(row) == (True, 9), tuple(row)
|
||||||
|
# 3) листинг без привязки — дом не трогается, листинг обогащается
|
||||||
|
before = db.execute(
|
||||||
|
text("SELECT count(*) FROM houses WHERE house_type='brick' AND source='test'")
|
||||||
|
).scalar_one()
|
||||||
|
assert (
|
||||||
|
save_detail_enrichment(db, _enrichment(item_id="t3036-2", house_type="brick")) is True
|
||||||
|
)
|
||||||
|
after = db.execute(
|
||||||
|
text("SELECT count(*) FROM houses WHERE house_type='brick' AND source='test'")
|
||||||
|
).scalar_one()
|
||||||
|
assert after == before
|
||||||
|
finally:
|
||||||
|
_cleanup(db)
|
||||||
61
tradein-mvp/backend/tests/test_avito_offers_per_page.py
Normal file
61
tradein-mvp/backend/tests/test_avito_offers_per_page.py
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
"""Регрессия на размер страницы Avito SERP.
|
||||||
|
|
||||||
|
Замер живьём 2026-08-21 (tradein-browser / camoufox, JS исполняется): страница
|
||||||
|
выдачи отдаёт 59-60 уникальных `data-item-id`. Константа стояла 50 — то есть
|
||||||
|
`ceil(total / PAGE)` завышал число страниц примерно на 17 %, и каждый лишний
|
||||||
|
запрос — лишний шанс словить SERP firewall.
|
||||||
|
|
||||||
|
Тест намеренно проверяет НЕ только значение, но и то, что константа
|
||||||
|
по-прежнему участвует в расчёте страниц: иначе правку значения легко потерять
|
||||||
|
при рефакторинге, оставив мёртвую константу.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from scraper_kit.providers.avito import serp
|
||||||
|
|
||||||
|
_SERP_SOURCE = Path(serp.__file__)
|
||||||
|
|
||||||
|
|
||||||
|
def test_offers_per_page_matches_live_measurement() -> None:
|
||||||
|
"""60, а не 50 — замер 2026-08-21 через реальный браузер."""
|
||||||
|
assert serp._AVITO_OFFERS_PER_PAGE == 60
|
||||||
|
|
||||||
|
|
||||||
|
def test_pages_needed_arithmetic_uses_the_constant() -> None:
|
||||||
|
"""`ceil(total / PAGE)` при 60 даёт меньше страниц, чем при 50.
|
||||||
|
|
||||||
|
Закрепляет направление эффекта: занижение размера страницы ЗАВЫШАЛО число
|
||||||
|
запрашиваемых страниц. В PR-разборе это было сформулировано наоборот, и
|
||||||
|
тест существует, чтобы неверная трактовка не вернулась.
|
||||||
|
"""
|
||||||
|
total = 1000
|
||||||
|
assert math.ceil(total / 60) == 17
|
||||||
|
assert math.ceil(total / 50) == 20
|
||||||
|
assert math.ceil(total / serp._AVITO_OFFERS_PER_PAGE) == 17
|
||||||
|
|
||||||
|
|
||||||
|
def test_constant_is_still_wired_into_pagination() -> None:
|
||||||
|
"""Константа реально используется, а не осталась мёртвой после правки."""
|
||||||
|
tree = ast.parse(_SERP_SOURCE.read_text(encoding="utf-8"))
|
||||||
|
uses = sum(
|
||||||
|
1
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Name) and node.id == "_AVITO_OFFERS_PER_PAGE"
|
||||||
|
)
|
||||||
|
# 1 присваивание + минимум два места расчёта (pages_needed в бисекции и в
|
||||||
|
# пагинации листа); если станет меньше — кто-то отвязал константу.
|
||||||
|
assert uses >= 3, f"_AVITO_OFFERS_PER_PAGE упоминается {uses} раз, ожидалось >= 3"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_stale_fifty_in_page_size_comment() -> None:
|
||||||
|
"""Комментарий рядом с константой не должен утверждать «~50 карточек»."""
|
||||||
|
src = _SERP_SOURCE.read_text(encoding="utf-8")
|
||||||
|
marker = "_AVITO_OFFERS_PER_PAGE = "
|
||||||
|
idx = src.index(marker)
|
||||||
|
preceding = src[max(0, idx - 1200) : idx]
|
||||||
|
assert "~50 карточек" not in preceding
|
||||||
|
|
@ -36,6 +36,16 @@ Per-provider модель (#1793):
|
||||||
BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска (default: 15)
|
BROWSER_RECYCLE_PAGES — страниц в одном сеансе браузера до перезапуска (default: 15)
|
||||||
BROWSER_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000)
|
BROWSER_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000)
|
||||||
BROWSER_WAIT_MS — ожидание гидрации listings после DOMContentLoaded, мс (default: 6000)
|
BROWSER_WAIT_MS — ожидание гидрации listings после DOMContentLoaded, мс (default: 6000)
|
||||||
|
BROWSER_CHALLENGE_WAIT_MS — бюджет ожидания QRATOR PoW-челленджа Авито (#3045),
|
||||||
|
мс (default: 30000). Челлендж-страница сама считает
|
||||||
|
proof-of-work в JS, ставит куку pow_solved и через
|
||||||
|
setTimeout(3000) делает window.location = location.href
|
||||||
|
(self-reload, URL не меняется). /fetch опрашивает
|
||||||
|
page.content() пока маркеры челленджа не исчезнут; по
|
||||||
|
истечении бюджета — ChallengeTimeoutError вместо тихой
|
||||||
|
отдачи заглушки. Действует ТОЛЬКО при обнаружении
|
||||||
|
маркеров челленджа в разметке — прочие провайдеры этот
|
||||||
|
путь никогда не задевают.
|
||||||
BROWSER_BLOCK_RESOURCE_TYPES — CSV типов ресурсов Playwright, которые abort'ить
|
BROWSER_BLOCK_RESOURCE_TYPES — CSV типов ресурсов Playwright, которые abort'ить
|
||||||
через page.route при навигации (default: "font,media").
|
через page.route при навигации (default: "font,media").
|
||||||
Снижает число одновременных под-коннектов на страницу →
|
Снижает число одновременных под-коннектов на страницу →
|
||||||
|
|
@ -113,6 +123,17 @@ BROWSER_NAV_TIMEOUT_MS: int = int(os.environ.get("BROWSER_NAV_TIMEOUT_MS", "6000
|
||||||
# выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31.
|
# выдача (~50 карточек, 3.2МБ). Подтверждено прод-дебагом 2026-05-31.
|
||||||
BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
|
BROWSER_WAIT_MS: int = int(os.environ.get("BROWSER_WAIT_MS", "6000"))
|
||||||
|
|
||||||
|
# 30000: цепочка PoW-челленджа Авито — startPow() в JS, затем setTimeout(3000) на
|
||||||
|
# self-reload, затем повторная гидрация страницы. 3с таймера самой площадки — это
|
||||||
|
# ТОЛЬКО задержка перед reload, не бюджет на сам расчёт PoW: под headless-браузером
|
||||||
|
# и egress-прокси решение может занять заметно дольше, чем в обычном браузере
|
||||||
|
# пользователя. Живой замер 2026-08-21 (#3045): без ожидания челленджа 4 из 6
|
||||||
|
# карточек с органической навигацией отдавали 7891-байтную челлендж-страницу вместо
|
||||||
|
# контента — не бан (403/429 не было), просто уходили раньше, чем страница себя
|
||||||
|
# перезагрузила. 30с — запас с кратным резервом на решение + reload + догидрацию,
|
||||||
|
# не превращающий единичный фетч в минуту ожидания при реальном бане/сетевой пробе.
|
||||||
|
BROWSER_CHALLENGE_WAIT_MS: int = int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000"))
|
||||||
|
|
||||||
# /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало:
|
# /fetch-json settle после goto(origin) перед in-page fetch (#1917). 500мс мало:
|
||||||
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
|
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
|
||||||
# сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом).
|
# сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом).
|
||||||
|
|
@ -898,6 +919,138 @@ async def _do_fetch(
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# ── QRATOR PoW-челлендж Авито (#3045) ────────────────────────────────────────────
|
||||||
|
# Маркеры сняты живьём с challenge-страницы Авито 2026-08-21 (замер: 6 карточек,
|
||||||
|
# органическая навигация из выдачи, 4/6 ушли с челленджа раньше времени). Разметка
|
||||||
|
# площадки может поменяться со временем — при протухании маркеров переснять их
|
||||||
|
# заново вживую, а не гадать по памяти. Форма провайдер-агностична: детектор просто
|
||||||
|
# ищет строки в HTML, другие площадки (cian/yandex/generic) их никогда не отдают,
|
||||||
|
# поэтому ветка ожидания для них не включается.
|
||||||
|
|
||||||
|
# Признак самого челленджа: JS-функция startPow(), которую страница вызывает в
|
||||||
|
# DOMContentLoaded (см. хвост challenge-скрипта в #3045), либо заголовок блока
|
||||||
|
# «Доступ ограничен: проверка безопасности» — оба встречались на снятых страницах.
|
||||||
|
_CHALLENGE_MARKERS: tuple[str, ...] = (
|
||||||
|
"startpow",
|
||||||
|
"доступ ограничен: проверка безопасности",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Признак БАН-страницы (не челлендж): «Доступ ограничен: проблема с IP» — статика
|
||||||
|
# без PoW-скрипта, приходит с 403/429 и заметно меньше challenge-страницы весом.
|
||||||
|
# Ждать тут бессмысленно — адрес заблокирован, а не временно проверяется.
|
||||||
|
_BAN_MARKERS: tuple[str, ...] = ("доступ ограничен: проблема с ip",)
|
||||||
|
|
||||||
|
|
||||||
|
class ChallengeTimeoutError(Exception):
|
||||||
|
"""PoW-челлендж не снялся за BROWSER_CHALLENGE_WAIT_MS.
|
||||||
|
|
||||||
|
Caller должен трактовать как временный отказ (retry/backoff), НЕ как валидный
|
||||||
|
контент — раньше caller получал 7891-байтную challenge-страницу и парсер либо
|
||||||
|
падал на ней, либо молча ничего не находил (#3045).
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BanPageDetectedError(Exception):
|
||||||
|
"""Площадка отдала бан-страницу («проблема с IP») вместо контента/челленджа.
|
||||||
|
|
||||||
|
В отличие от ChallengeTimeoutError ждать здесь бессмысленно: адрес забанен, а
|
||||||
|
не проходит временную проверку — поднимается сразу, без траты
|
||||||
|
BROWSER_CHALLENGE_WAIT_MS.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_pow_challenge(html: str) -> bool:
|
||||||
|
"""True, если HTML — QRATOR PoW-челлендж Авито (см. _CHALLENGE_MARKERS)."""
|
||||||
|
lower = html.lower()
|
||||||
|
return any(marker in lower for marker in _CHALLENGE_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ban_page(html: str) -> bool:
|
||||||
|
"""True, если HTML — бан-страница «проблема с IP» (см. _BAN_MARKERS)."""
|
||||||
|
lower = html.lower()
|
||||||
|
return any(marker in lower for marker in _BAN_MARKERS)
|
||||||
|
|
||||||
|
|
||||||
|
# Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по
|
||||||
|
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
|
||||||
|
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
|
||||||
|
# этого случая.
|
||||||
|
_NAVIGATION_RACE_MARKERS: tuple[str, ...] = (
|
||||||
|
"execution context was destroyed",
|
||||||
|
"most likely because of a navigation",
|
||||||
|
"page is navigating",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _content_during_navigation(page: object) -> str | None:
|
||||||
|
"""`page.content()`, устойчивый к перезагрузке страницы под руками.
|
||||||
|
|
||||||
|
PoW-челлендж перезагружает себя сам (`window.location = location.href`), и
|
||||||
|
вызов content(), попавший ровно в этот момент, кидает «Execution context was
|
||||||
|
destroyed». Для нас это НЕ ошибка, а признак того, что перезагрузка — та
|
||||||
|
самая, которую мы ждём, — идёт прямо сейчас. Возвращаем None = «ещё не
|
||||||
|
устоялось, опроси снова», а не роняем фетч на самом успешном исходе.
|
||||||
|
|
||||||
|
Всё остальное (закрытая страница, упавший браузер) пробрасываем как есть.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return await page.content() # type: ignore[attr-defined]
|
||||||
|
except Exception as exc: # noqa: BLE001 — тип не импортируем, различаем по тексту
|
||||||
|
text = str(exc).lower()
|
||||||
|
if any(marker in text for marker in _NAVIGATION_RACE_MARKERS):
|
||||||
|
return None
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
|
||||||
|
"""Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа.
|
||||||
|
|
||||||
|
Страница перезагружает СЕБЯ САМА (`window.location = location.href`) после
|
||||||
|
решения PoW — URL не меняется, поэтому page.wait_for_url тут не годится;
|
||||||
|
опрашиваем контент с шагом ~1с вместо этого. По истечении
|
||||||
|
BROWSER_CHALLENGE_WAIT_MS — ChallengeTimeoutError, а не тихая отдача
|
||||||
|
challenge-страницы как будто это валидный контент.
|
||||||
|
|
||||||
|
После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS,
|
||||||
|
каким ждём обычную навигацию (второй таймаут не изобретаем).
|
||||||
|
"""
|
||||||
|
poll_interval_ms = 1000
|
||||||
|
elapsed_ms = 0
|
||||||
|
html: str | None = await _content_during_navigation(page)
|
||||||
|
while (html is None or _is_pow_challenge(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
|
||||||
|
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
|
||||||
|
elapsed_ms += poll_interval_ms
|
||||||
|
html = await _content_during_navigation(page)
|
||||||
|
|
||||||
|
if html is None or _is_pow_challenge(html):
|
||||||
|
raise ChallengeTimeoutError(
|
||||||
|
f"tradein-browser[{provider}]: PoW-челлендж не снялся за "
|
||||||
|
f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"tradein-browser[%s]: PoW-челлендж снят за ~%dмс, догидрация url=%r",
|
||||||
|
provider,
|
||||||
|
elapsed_ms,
|
||||||
|
url,
|
||||||
|
)
|
||||||
|
if BROWSER_WAIT_MS > 0:
|
||||||
|
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
||||||
|
settled = await _content_during_navigation(page)
|
||||||
|
if settled is None:
|
||||||
|
# Догидрация совпала с ещё одной навигацией — даём один короткий добор
|
||||||
|
# вместо того, чтобы падать: контент уже не challenge, гонка чисто
|
||||||
|
# техническая.
|
||||||
|
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
|
||||||
|
settled = await _content_during_navigation(page)
|
||||||
|
if settled is None:
|
||||||
|
raise ChallengeTimeoutError(
|
||||||
|
f"tradein-browser[{provider}]: челлендж снят, но страница не устоялась "
|
||||||
|
f"(навигация не прекращается) url={url!r}"
|
||||||
|
)
|
||||||
|
return settled
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_once(
|
async def _fetch_once(
|
||||||
provider: str,
|
provider: str,
|
||||||
url: str,
|
url: str,
|
||||||
|
|
@ -950,6 +1103,17 @@ async def _fetch_once(
|
||||||
if BROWSER_WAIT_MS > 0:
|
if BROWSER_WAIT_MS > 0:
|
||||||
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
|
||||||
html: str = await page.content() # type: ignore[attr-defined]
|
html: str = await page.content() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
# Бан-страница («проблема с IP») распознаётся и падает СРАЗУ, без траты
|
||||||
|
# BROWSER_CHALLENGE_WAIT_MS — ждать там нечего, адрес заблокирован (#3045).
|
||||||
|
if _is_ban_page(html):
|
||||||
|
raise BanPageDetectedError(
|
||||||
|
f"tradein-browser[{provider}]: бан-страница (проблема с IP) url={url!r}"
|
||||||
|
)
|
||||||
|
# PoW-челлендж (QRATOR) — в отличие от бана снимается сам по себе; ждём его
|
||||||
|
# прохождения вместо того, чтобы вернуть 7891-байтную заглушку как контент.
|
||||||
|
if _is_pow_challenge(html):
|
||||||
|
html = await _wait_out_pow_challenge(page, provider, url)
|
||||||
finally:
|
finally:
|
||||||
await page.close() # type: ignore[attr-defined]
|
await page.close() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
|
|
||||||
273
tradein-mvp/browser/test_server_pow_challenge.py
Normal file
273
tradein-mvp/browser/test_server_pow_challenge.py
Normal file
|
|
@ -0,0 +1,273 @@
|
||||||
|
"""test_server_pow_challenge.py — QRATOR PoW-челлендж Авито в /fetch (#3045).
|
||||||
|
|
||||||
|
Проблема, установленная замером на проде 2026-08-21: Авито за QRATOR отдаёт
|
||||||
|
proof-of-work челлендж (JS считает PoW, ставит куку pow_solved, через
|
||||||
|
setTimeout(3000) делает `window.location = location.href` — self-reload, URL не
|
||||||
|
меняется). Фиксированной паузы BROWSER_WAIT_MS (6с) на цепочку
|
||||||
|
«PoW → таймер 3с → перезагрузка → гидрация» хватало не всегда: живой замер дал
|
||||||
|
2 успеха из 6 карточек, 4 отказа — все «проверка безопасности», НЕ бан по IP.
|
||||||
|
|
||||||
|
Тестируется _fetch_once целиком (не только вынесенный polling-хелпер), чтобы
|
||||||
|
проверить весь путь: goto → content() → детект → (ожидание | ошибка) → return.
|
||||||
|
|
||||||
|
camoufox НЕ запускается: _browsers[provider] — поддельный browser/page,
|
||||||
|
возвращающие заранее заданную последовательность content(). wait_for_timeout
|
||||||
|
на фейковой page — no-op (без реального asyncio.sleep), поэтому тесты идут
|
||||||
|
мгновенно независимо от величины BROWSER_CHALLENGE_WAIT_MS/BROWSER_WAIT_MS.
|
||||||
|
|
||||||
|
Запуск (из tradein-mvp/browser/)::
|
||||||
|
|
||||||
|
python -m pytest test_server_pow_challenge.py -q
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import importlib.util
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# server.py — не пакет (отдельный сервис без __init__/pyproject). Грузим по пути.
|
||||||
|
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
|
||||||
|
_spec = importlib.util.spec_from_file_location("tradein_browser_server", _SERVER_PATH)
|
||||||
|
assert _spec is not None and _spec.loader is not None
|
||||||
|
server = importlib.util.module_from_spec(_spec)
|
||||||
|
_spec.loader.exec_module(server)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Чистое per-provider состояние на каждый тест (зеркалит test_server_smoke.py)."""
|
||||||
|
monkeypatch.setattr(server, "_browsers", {})
|
||||||
|
monkeypatch.setattr(server, "_browser_cms", {})
|
||||||
|
monkeypatch.setattr(server, "_page_counters", {})
|
||||||
|
monkeypatch.setattr(server, "_locks", {})
|
||||||
|
monkeypatch.setattr(server, "_retry_tasks", {})
|
||||||
|
monkeypatch.setattr(server, "_locks_guard", asyncio.Lock())
|
||||||
|
|
||||||
|
|
||||||
|
_CHALLENGE_HTML = (
|
||||||
|
"<html><body><script>"
|
||||||
|
"document.addEventListener('DOMContentLoaded', function() {"
|
||||||
|
" if (getCookie('pow_solved')) {"
|
||||||
|
" setTimeout(function() { window.location = location.href; }, 3000);"
|
||||||
|
" return;"
|
||||||
|
" }"
|
||||||
|
" startPow(1);"
|
||||||
|
"});"
|
||||||
|
"</script></body></html>"
|
||||||
|
)
|
||||||
|
_BAN_HTML = "<html><body>Доступ ограничен: проблема с IP</body></html>"
|
||||||
|
_REAL_HTML = "<html><body>listing card content</body></html>"
|
||||||
|
|
||||||
|
|
||||||
|
class _ChallengePage:
|
||||||
|
"""Поддельная page: отдаёт заданную последовательность content() по вызовам.
|
||||||
|
|
||||||
|
После исчерпания списка повторяет последний элемент (имитирует «страница
|
||||||
|
осталась в этом состоянии»). Фиксирует goto/wait_for_timeout-вызовы для
|
||||||
|
проверки, что бюджет ожидания не тратится там, где не должен.
|
||||||
|
|
||||||
|
Элемент последовательности может быть исключением — тогда content() его
|
||||||
|
поднимает. Это нужно, чтобы воспроизвести гонку с self-reload челленджа:
|
||||||
|
playwright кидает «Execution context was destroyed» ровно в момент той
|
||||||
|
перезагрузки, которую мы ждём, и на моках без этого дефект не виден.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, html_sequence: list[str | Exception]) -> None:
|
||||||
|
self._html_sequence = html_sequence
|
||||||
|
self._call_count = 0
|
||||||
|
self.goto_urls: list[str] = []
|
||||||
|
self.wait_for_timeout_calls: list[int] = []
|
||||||
|
self.closed = 0
|
||||||
|
|
||||||
|
async def route(self, pattern: str, handler: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def goto(self, url: str, **kwargs: Any) -> None:
|
||||||
|
self.goto_urls.append(url)
|
||||||
|
|
||||||
|
async def wait_for_timeout(self, ms: int) -> None:
|
||||||
|
self.wait_for_timeout_calls.append(ms)
|
||||||
|
|
||||||
|
async def content(self) -> str:
|
||||||
|
idx = min(self._call_count, len(self._html_sequence) - 1)
|
||||||
|
item = self._html_sequence[idx]
|
||||||
|
self._call_count += 1
|
||||||
|
if isinstance(item, Exception):
|
||||||
|
raise item
|
||||||
|
return item
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
self.closed += 1
|
||||||
|
|
||||||
|
|
||||||
|
class _ChallengeBrowser:
|
||||||
|
def __init__(self, page: _ChallengePage) -> None:
|
||||||
|
self._page = page
|
||||||
|
|
||||||
|
async def new_page(self) -> _ChallengePage:
|
||||||
|
return self._page
|
||||||
|
|
||||||
|
|
||||||
|
def _install(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, page: _ChallengePage, provider: str = "avito"
|
||||||
|
) -> None:
|
||||||
|
server._browsers[provider] = _ChallengeBrowser(page)
|
||||||
|
monkeypatch.setattr(server, "BROWSER_RECYCLE_PAGES", 10_000)
|
||||||
|
|
||||||
|
|
||||||
|
# ── детекторы: чистые функции ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_pow_challenge_true_on_start_pow_marker() -> None:
|
||||||
|
assert server._is_pow_challenge(_CHALLENGE_HTML) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_pow_challenge_true_on_title_marker() -> None:
|
||||||
|
html = "<html><body>Доступ ограничен: проверка безопасности</body></html>"
|
||||||
|
assert server._is_pow_challenge(html) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_pow_challenge_false_on_ban_page() -> None:
|
||||||
|
"""Бан-страница НЕ должна ложно матчиться как челлендж — разные ветки."""
|
||||||
|
assert server._is_pow_challenge(_BAN_HTML) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_pow_challenge_false_on_real_content() -> None:
|
||||||
|
assert server._is_pow_challenge(_REAL_HTML) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_ban_page_true_on_ip_problem_marker() -> None:
|
||||||
|
assert server._is_ban_page(_BAN_HTML) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_ban_page_false_on_challenge_page() -> None:
|
||||||
|
assert server._is_ban_page(_CHALLENGE_HTML) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_ban_page_false_on_real_content() -> None:
|
||||||
|
assert server._is_ban_page(_REAL_HTML) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_challenge_wait_budget_defaults_to_30s(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
"""Дефолт BROWSER_CHALLENGE_WAIT_MS=30000 без переопределения env."""
|
||||||
|
monkeypatch.delenv("BROWSER_CHALLENGE_WAIT_MS", raising=False)
|
||||||
|
import os
|
||||||
|
|
||||||
|
assert int(os.environ.get("BROWSER_CHALLENGE_WAIT_MS", "30000")) == 30000
|
||||||
|
|
||||||
|
|
||||||
|
# ── _fetch_once: сценарии ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_once_waits_out_challenge_then_returns_real_content(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Челлендж на первых двух опросах, третий — уже реальный контент → он и вернётся."""
|
||||||
|
page = _ChallengePage([_CHALLENGE_HTML, _CHALLENGE_HTML, _REAL_HTML])
|
||||||
|
_install(monkeypatch, page)
|
||||||
|
|
||||||
|
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||||||
|
|
||||||
|
assert html == _REAL_HTML
|
||||||
|
assert page.closed == 1
|
||||||
|
# Обычная гидро-пауза + минимум одна пауза опроса челленджа + финальная
|
||||||
|
# догидрация тем же BROWSER_WAIT_MS (второй таймаут не изобретаем).
|
||||||
|
assert server.BROWSER_WAIT_MS in page.wait_for_timeout_calls
|
||||||
|
assert 1000 in page.wait_for_timeout_calls # шаг опроса
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_once_raises_challenge_timeout_when_budget_exhausted(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Челлендж не снимается никогда → ChallengeTimeoutError, а не заглушка-контент."""
|
||||||
|
monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 2000)
|
||||||
|
page = _ChallengePage([_CHALLENGE_HTML]) # всегда челлендж (последний элемент повторяется)
|
||||||
|
_install(monkeypatch, page)
|
||||||
|
|
||||||
|
with pytest.raises(server.ChallengeTimeoutError):
|
||||||
|
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||||||
|
|
||||||
|
# finally всё равно закрывает страницу, несмотря на исключение.
|
||||||
|
assert page.closed == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_once_raises_ban_error_immediately_without_spending_budget(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Бан-страница («проблема с IP») → своя ошибка сразу, БЕЗ цикла опроса."""
|
||||||
|
page = _ChallengePage([_BAN_HTML])
|
||||||
|
_install(monkeypatch, page)
|
||||||
|
|
||||||
|
with pytest.raises(server.BanPageDetectedError):
|
||||||
|
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||||||
|
|
||||||
|
assert page.closed == 1
|
||||||
|
# Единственный wait_for_timeout — обычная гидро-пауза ДО детекта; опроса
|
||||||
|
# челленджа (доп. паузы по 1000мс) не было — бюджет не потрачен.
|
||||||
|
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_once_normal_page_without_markers_unaffected(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Страница без маркеров челленджа/бана → поведение как раньше, без polling-ветки."""
|
||||||
|
page = _ChallengePage([_REAL_HTML])
|
||||||
|
_install(monkeypatch, page)
|
||||||
|
|
||||||
|
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/card/1"))
|
||||||
|
|
||||||
|
assert html == _REAL_HTML
|
||||||
|
assert page.closed == 1
|
||||||
|
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
|
||||||
|
assert page.goto_urls == ["https://www.avito.ru/card/1"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── гонка с self-reload челленджа (#3045, найдено при ревью ветки) ──────────────
|
||||||
|
#
|
||||||
|
# Челлендж перезагружает страницу САМ. Вызов page.content(), попавший ровно в этот
|
||||||
|
# момент, кидает «Execution context was destroyed» — то есть цикл ожидания падал бы
|
||||||
|
# именно на успешном исходе, ради которого написан. На моках без явной имитации
|
||||||
|
# это не воспроизводится, поэтому тесты ниже поднимают исключение из content().
|
||||||
|
|
||||||
|
_NAV_RACE = RuntimeError(
|
||||||
|
"Execution context was destroyed, most likely because of a navigation."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_navigation_race_during_reload_is_not_a_failure(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""content() упал на перезагрузке → опрашиваем дальше, отдаём настоящий HTML."""
|
||||||
|
page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE, _REAL_HTML])
|
||||||
|
_install(monkeypatch, page)
|
||||||
|
|
||||||
|
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||||||
|
|
||||||
|
assert html == _REAL_HTML
|
||||||
|
|
||||||
|
|
||||||
|
def test_permanent_navigation_race_raises_challenge_timeout(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Навигация не прекращается → своя ошибка, а не сырое исключение playwright."""
|
||||||
|
page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE])
|
||||||
|
_install(monkeypatch, page)
|
||||||
|
|
||||||
|
with pytest.raises(server.ChallengeTimeoutError):
|
||||||
|
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_unrelated_content_error_still_propagates(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
"""Глушим ТОЛЬКО гонку навигации; упавший браузер должен всплыть как есть."""
|
||||||
|
boom = RuntimeError("Target page, context or browser has been closed")
|
||||||
|
page = _ChallengePage([_CHALLENGE_HTML, boom])
|
||||||
|
_install(monkeypatch, page)
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError, match="has been closed"):
|
||||||
|
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
|
||||||
|
|
@ -23,15 +23,26 @@
|
||||||
# S3_BUCKET=gendesign-backups
|
# S3_BUCKET=gendesign-backups
|
||||||
# S3_ACCESS_KEY=...
|
# S3_ACCESS_KEY=...
|
||||||
# S3_SECRET_KEY=...
|
# S3_SECRET_KEY=...
|
||||||
|
#
|
||||||
|
# Детект пропущенного запуска (#2203): по успеху пишется sentinel-файл
|
||||||
|
# (SENTINEL_FILE) — отдельный cron-запуск ops/check-backup-staleness.sh
|
||||||
|
# следит за его возрастом и шлёт алерт, если он не свежел (см. заголовок
|
||||||
|
# того скрипта — канал алертов и cron-строка). Сам этот скрипт не алертит,
|
||||||
|
# только отмечает успех.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=../../ops/lib-backup.sh
|
||||||
|
source "$SCRIPT_DIR/../../ops/lib-backup.sh"
|
||||||
|
|
||||||
BACKUP_DIR="${BACKUP_DIR:-/opt/gendesign/backups/tradein}"
|
BACKUP_DIR="${BACKUP_DIR:-/opt/gendesign/backups/tradein}"
|
||||||
KEEP="${KEEP:-7}" # сколько копий хранить
|
KEEP="${KEEP:-7}" # сколько копий хранить
|
||||||
PG_CONTAINER="${PG_CONTAINER:-tradein-postgres}"
|
PG_CONTAINER="${PG_CONTAINER:-tradein-postgres}"
|
||||||
MIN_DUMP_BYTES="${MIN_DUMP_BYTES:-10240}" # 10 KiB floor — пустая/битая
|
MIN_DUMP_BYTES="${MIN_DUMP_BYTES:-10240}" # 10 KiB floor — пустая/битая
|
||||||
# схема заведомо меньше, живая
|
# схема заведомо меньше, живая
|
||||||
# БД — на порядки больше.
|
# БД — на порядки больше.
|
||||||
|
SENTINEL_FILE="${SENTINEL_FILE:-${BACKUP_DIR}/.last_success}"
|
||||||
|
|
||||||
# S3-переменные: свой env-файл, а если его нет — общий с main-бэкапом.
|
# S3-переменные: свой env-файл, а если его нет — общий с main-бэкапом.
|
||||||
if [[ -f /etc/default/tradein-backup ]]; then
|
if [[ -f /etc/default/tradein-backup ]]; then
|
||||||
|
|
@ -40,8 +51,6 @@ elif [[ -f /etc/default/gendesign-backup ]]; then
|
||||||
source /etc/default/gendesign-backup
|
source /etc/default/gendesign-backup
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
|
||||||
|
|
||||||
# Проверка целостности сверх «не пустой»: битый посреди записи дамп (диск
|
# Проверка целостности сверх «не пустой»: битый посреди записи дамп (диск
|
||||||
# кончился, OOM-kill, оборвался docker exec) может дать структурно валидный,
|
# кончился, OOM-kill, оборвался docker exec) может дать структурно валидный,
|
||||||
# не крошечный .gz — ни `-s`, ни MIN_DUMP_BYTES это не ловят. Две проверки,
|
# не крошечный .gz — ни `-s`, ни MIN_DUMP_BYTES это не ловят. Две проверки,
|
||||||
|
|
@ -155,4 +164,9 @@ ls -1t "$BACKUP_DIR"/tradein-globals-*.sql.gz 2>/dev/null \
|
||||||
size=$(du -h "$out" | cut -f1)
|
size=$(du -h "$out" | cut -f1)
|
||||||
count=$(ls -1 "$BACKUP_DIR"/tradein-[0-9]*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
|
count=$(ls -1 "$BACKUP_DIR"/tradein-[0-9]*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
|
||||||
globals_count=$(ls -1 "$BACKUP_DIR"/tradein-globals-*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
|
globals_count=$(ls -1 "$BACKUP_DIR"/tradein-globals-*.sql.gz 2>/dev/null | wc -l | tr -d ' ')
|
||||||
echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] backup ok: $out ($size), копий хранится: $count данных + $globals_count globals"
|
log "backup ok: $out ($size), копий хранится: $count данных + $globals_count globals"
|
||||||
|
|
||||||
|
# Детект пропущенного запуска (#2203): отмечаем успех только тут, после всех
|
||||||
|
# проверок выше (и — под `set -euo pipefail` — после S3-выгрузки, если она
|
||||||
|
# включена). См. ops/check-backup-staleness.sh.
|
||||||
|
write_sentinel "$SENTINEL_FILE"
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@
|
||||||
bathroom_type, windows_view, repair_state, sale_type, mortgage_available)
|
bathroom_type, windows_view, repair_state, sale_type, mortgage_available)
|
||||||
- Location (lat, lon, avito_location_id, metro_stations[], address_full)
|
- Location (lat, lon, avito_location_id, metro_stations[], address_full)
|
||||||
- House params (house_type, total_floors_house, lifts, concierge, closed_yard,
|
- House params (house_type, total_floors_house, lifts, concierge, closed_yard,
|
||||||
house_catalog_url) — собираются но НЕ сохраняются в БД (Stage 2c)
|
house_catalog_url) — house_type/total_floors/concierge/closed_yard доезжают в
|
||||||
|
houses (fill-only, через listings.house_id_fk, #3036); лифты — нет колонок в houses
|
||||||
- Description
|
- Description
|
||||||
- Domoteka 5 полей (owners_count, owners_at_least, last_owner_change_date,
|
- Domoteka 5 полей (owners_count, owners_at_least, last_owner_change_date,
|
||||||
encumbrances_clean, registry_match)
|
encumbrances_clean, registry_match)
|
||||||
|
|
@ -860,6 +861,60 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
|
||||||
|
|
||||||
|
|
||||||
# ── save_detail_enrichment ────────────────────────────────────────────────────
|
# ── save_detail_enrichment ────────────────────────────────────────────────────
|
||||||
|
# #3036: house-level поля с детальной страницы → houses (fill-only). Колонок под лифты
|
||||||
|
# в houses нет — passenger/cargo_elevators намеренно не в списке.
|
||||||
|
_HOUSE_PARAMS_SQL = text("""
|
||||||
|
UPDATE houses h SET
|
||||||
|
has_concierge = COALESCE(h.has_concierge, CAST(:has_concierge AS boolean)),
|
||||||
|
closed_yard = COALESCE(h.closed_yard, CAST(:closed_yard AS boolean)),
|
||||||
|
total_floors = COALESCE(h.total_floors, CAST(:total_floors_house AS integer)),
|
||||||
|
house_type = COALESCE(h.house_type, CAST(:house_type AS text))
|
||||||
|
FROM listings l
|
||||||
|
WHERE l.source = 'avito' AND l.source_id = :item_id
|
||||||
|
AND l.house_id_fk = h.id
|
||||||
|
AND (h.has_concierge IS NULL AND CAST(:has_concierge AS boolean) IS NOT NULL
|
||||||
|
OR h.closed_yard IS NULL AND CAST(:closed_yard AS boolean) IS NOT NULL
|
||||||
|
OR h.total_floors IS NULL AND CAST(:total_floors_house AS integer) IS NOT NULL
|
||||||
|
OR h.house_type IS NULL AND CAST(:house_type AS text) IS NOT NULL)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def _fill_house_params_from_detail(db: Session, e: DetailEnrichment) -> int:
|
||||||
|
"""Заполнить пустые house-поля дома листинга значениями с детальной страницы (#3036).
|
||||||
|
|
||||||
|
Возвращает число обновлённых домов (0 — листинг без house_id_fk, у дома всё уже
|
||||||
|
заполнено, либо с карточки ничего не пришло). Ошибка оператора глушится под
|
||||||
|
SAVEPOINT с warning: обогащение листинга важнее.
|
||||||
|
"""
|
||||||
|
if (
|
||||||
|
e.has_concierge is None
|
||||||
|
and e.closed_yard is None
|
||||||
|
and e.total_floors_house is None
|
||||||
|
and e.house_type is None
|
||||||
|
):
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
with db.begin_nested():
|
||||||
|
r = db.execute(
|
||||||
|
_HOUSE_PARAMS_SQL,
|
||||||
|
{
|
||||||
|
"item_id": e.item_id,
|
||||||
|
"has_concierge": e.has_concierge,
|
||||||
|
"closed_yard": e.closed_yard,
|
||||||
|
"total_floors_house": e.total_floors_house,
|
||||||
|
"house_type": e.house_type,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"save_detail_enrichment: house params not saved for item_id=%s",
|
||||||
|
e.item_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
return int(r.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
|
def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
|
||||||
"""UPDATE listings SET <25+ cols> WHERE source='avito' AND source_id=:item_id.
|
"""UPDATE listings SET <25+ cols> WHERE source='avito' AND source_id=:item_id.
|
||||||
|
|
||||||
|
|
@ -872,9 +927,14 @@ def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
|
||||||
house_catalog_url колонки нет. house_type — COALESCE existing-first (не затираем
|
house_catalog_url колонки нет. house_type — COALESCE existing-first (не затираем
|
||||||
canonical из Houses Catalog Stage 2c); house_url — new-first additive (avito-owned
|
canonical из Houses Catalog Stage 2c); house_url — new-first additive (avito-owned
|
||||||
поле, нет конкурирующего canonical-писателя).
|
поле, нет конкурирующего canonical-писателя).
|
||||||
Остальные house-level поля (passenger_elevators, cargo_elevators, has_concierge,
|
House-level поля (has_concierge, closed_yard, total_floors_house, house_type)
|
||||||
closed_yard, total_floors_house) по-прежнему опускаются — нет колонок в listings,
|
пишутся в houses ЧЕРЕЗ listings.house_id_fk вторым оператором (#3036), fill-only:
|
||||||
canonical приходит из Houses Catalog Stage 2c (отдельный follow-up).
|
COALESCE(houses.X, новое) — не затираем канон из Houses Catalog Stage 2c, а
|
||||||
|
заполняем пустое. До этого они парсились на каждой карточке и выбрасывались:
|
||||||
|
на проде has_concierge был заполнен у 5 домов из 10 131, closed_yard — у 19,
|
||||||
|
при 10 051 обогащённых карточках Авито с привязкой к дому. Лифты
|
||||||
|
(passenger_elevators/cargo_elevators) по-прежнему опускаются — в houses нет колонок.
|
||||||
|
Под SAVEPOINT: отказ этого оператора не должен ронять обогащение самого листинга.
|
||||||
"""
|
"""
|
||||||
result = db.execute(
|
result = db.execute(
|
||||||
text("""
|
text("""
|
||||||
|
|
@ -952,6 +1012,8 @@ def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
|
||||||
"house_catalog_url": e.house_catalog_url,
|
"house_catalog_url": e.house_catalog_url,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if result.rowcount > 0:
|
||||||
|
_fill_house_params_from_detail(db, e)
|
||||||
db.commit()
|
db.commit()
|
||||||
found = result.rowcount > 0
|
found = result.rowcount > 0
|
||||||
if not found:
|
if not found:
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,11 @@ NOVOSTROYKA_SLUG = "novostroyka-ASgBAgICAkSSA8YQ5geOUg"
|
||||||
# сегмент. Цена вопроса: вторичка — 20,6 % общей выдачи ЕКБ, остальное раньше качалось
|
# сегмент. Цена вопроса: вторичка — 20,6 % общей выдачи ЕКБ, остальное раньше качалось
|
||||||
# и отбрасывалось после разбора (страницы и антибан-бюджет уже потрачены).
|
# и отбрасывалось после разбора (страницы и антибан-бюджет уже потрачены).
|
||||||
SECONDARY_PATH_SEGMENT = "vtorichka"
|
SECONDARY_PATH_SEGMENT = "vtorichka"
|
||||||
|
# Канонический slug категории «вторичка» — для путей БЕЗ комнатности (якорный
|
||||||
|
# fetch_around и citywide): редиректа не нужно, запрос один. Проверено вживую 21.08.2026
|
||||||
|
# через сайдкар с geoCoords/radius: 60 карточек, все vtorichka, total=9036 (против 55 карточек
|
||||||
|
# 41/14 у общей выдачи на том же якоре) — гео-параметры путь принимает.
|
||||||
|
SECONDARY_SLUG = "vtorichka-ASgBAgICAkSSA8YQ5geMUg"
|
||||||
|
|
||||||
# ── Exhaustive full-load (room×price bisection) ───────────────────────────────
|
# ── Exhaustive full-load (room×price bisection) ───────────────────────────────
|
||||||
# Верхняя граница цены при первом рекурсивном делении (нет явного hi).
|
# Верхняя граница цены при первом рекурсивном делении (нет явного hi).
|
||||||
|
|
@ -261,8 +266,22 @@ _AVITO_MIN_BRACKET = 50_000
|
||||||
# Последний брекет ОТКРЫТ (hi=None → pmax не ставится) — ловит весь хвост люкса
|
# Последний брекет ОТКРЫТ (hi=None → pmax не ставится) — ловит весь хвост люкса
|
||||||
# без потолка; он крошечный (avito >50М ≈ 73), пагинируется напрямую без бисекции.
|
# без потолка; он крошечный (avito >50М ≈ 73), пагинируется напрямую без бисекции.
|
||||||
_AVITO_PRICE_SEED_BRACKETS: list[tuple[int, int | None]] = get_price_seed_brackets()
|
_AVITO_PRICE_SEED_BRACKETS: list[tuple[int, int | None]] = get_price_seed_brackets()
|
||||||
# Avito SERP показывает ~50 карточек на страницу.
|
# Avito SERP отдаёт 60 карточек на страницу. Замер живьём 2026-08-21 через
|
||||||
_AVITO_OFFERS_PER_PAGE = 50
|
# tradein-browser (camoufox, JS исполняется): 59-60 уникальных `data-item-id`
|
||||||
|
# на странице выдачи. «Лишние» сверх 50 — обычные объявления с платным
|
||||||
|
# продвижением (`vas-icon_type-promoted`), они лежат в том же списке под
|
||||||
|
# `page-title/count`, а не отдельным рекламным блоком, и собираются наравне.
|
||||||
|
#
|
||||||
|
# Константа стояла 50 и участвует ТОЛЬКО в `ceil(total / PAGE)` — то есть
|
||||||
|
# занижение размера страницы ЗАВЫШАЛО расчётное число страниц, а не занижало:
|
||||||
|
# - запрашивали примерно на 17 % страниц больше, чем нужно (лишние запросы,
|
||||||
|
# лишняя экспозиция под SERP firewall — при нашей доле банов это и есть
|
||||||
|
# основная цена ошибки);
|
||||||
|
# - `tail_loss` считался как `total - cap * 50` и завышал потерю;
|
||||||
|
# - `complete` (пагинация листа) чаще ложно показывал «неполно».
|
||||||
|
# Тихой потери данных не было: условия «страница вернула меньше PAGE карточек,
|
||||||
|
# значит последняя» в коде нет — пагинация ограничена только `max_pages`.
|
||||||
|
_AVITO_OFFERS_PER_PAGE = 60
|
||||||
|
|
||||||
|
|
||||||
def _avito_bisection_config(cap: int) -> BisectionConfig:
|
def _avito_bisection_config(cap: int) -> BisectionConfig:
|
||||||
|
|
@ -829,9 +848,14 @@ class AvitoScraper(BaseScraper):
|
||||||
*,
|
*,
|
||||||
pages: int = 1,
|
pages: int = 1,
|
||||||
delay_override_sec: float | None = None,
|
delay_override_sec: float | None = None,
|
||||||
|
secondary_only: bool = True,
|
||||||
) -> list[ScrapedLot]:
|
) -> list[ScrapedLot]:
|
||||||
"""Найти объявления Авито вокруг (lat, lon) в radius_m метрах.
|
"""Найти объявления Авито вокруг (lat, lon) в radius_m метрах.
|
||||||
|
|
||||||
|
secondary_only (default True, #3033): путь вторички вместо общей выдачи — класс и
|
||||||
|
есть парсер вторички, новостройки ходят своим fetch_newbuildings. До этого якорный
|
||||||
|
city-sweep качал общую выдачу (в прогоне 4539: 41 vtorichka + 26 novostroyki).
|
||||||
|
|
||||||
Avito работает в км — конвертируем. Минимум 1 км.
|
Avito работает в км — конвертируем. Минимум 1 км.
|
||||||
|
|
||||||
pages=1 (default) — backward compat, одна страница (~50 lots).
|
pages=1 (default) — backward compat, одна страница (~50 lots).
|
||||||
|
|
@ -848,7 +872,7 @@ class AvitoScraper(BaseScraper):
|
||||||
|
|
||||||
all_lots: list[ScrapedLot] = []
|
all_lots: list[ScrapedLot] = []
|
||||||
for page in range(1, pages + 1):
|
for page in range(1, pages + 1):
|
||||||
url = self._build_web_url(lat, lon, radius_km, page=page)
|
url = self._build_web_url(lat, lon, radius_km, page=page, secondary=secondary_only)
|
||||||
try:
|
try:
|
||||||
html = await self._fetch_serp_html(url, page)
|
html = await self._fetch_serp_html(url, page)
|
||||||
except (AvitoBlockedError, AvitoRateLimitedError):
|
except (AvitoBlockedError, AvitoRateLimitedError):
|
||||||
|
|
@ -905,28 +929,38 @@ class AvitoScraper(BaseScraper):
|
||||||
билдерах скоупил oblast city-sweep на ЕКБ, невзирая на geoCoords/фильтр)."""
|
билдерах скоупил oblast city-sweep на ЕКБ, невзирая на geoCoords/фильтр)."""
|
||||||
return self._target_city_slug or "ekaterinburg"
|
return self._target_city_slug or "ekaterinburg"
|
||||||
|
|
||||||
def _build_web_url(self, lat: float, lon: float, radius_km: int, page: int = 1) -> str:
|
def _category_path(self, *, secondary: bool) -> str:
|
||||||
|
"""Сегмент категории: родной фильтр вторички (#3033) либо общая выдача."""
|
||||||
|
if secondary:
|
||||||
|
return f"/{self._city_seg()}/kvartiry/prodam/{SECONDARY_SLUG}"
|
||||||
|
return f"/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ"
|
||||||
|
|
||||||
|
def _build_web_url(
|
||||||
|
self,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
radius_km: int,
|
||||||
|
page: int = 1,
|
||||||
|
*,
|
||||||
|
secondary: bool = False,
|
||||||
|
) -> str:
|
||||||
|
"""URL якорного поиска (geoCoords + radius). secondary=True — путь вторички (#3033)."""
|
||||||
params = {
|
params = {
|
||||||
"geoCoords": f"{lat},{lon}",
|
"geoCoords": f"{lat},{lon}",
|
||||||
"radius": radius_km,
|
"radius": radius_km,
|
||||||
"s": 104, # sort by date
|
"s": 104, # sort by date
|
||||||
"p": page,
|
"p": page,
|
||||||
}
|
}
|
||||||
return (
|
return f"{self.base_url}{self._category_path(secondary=secondary)}?{urlencode(params)}"
|
||||||
f"{self.base_url}/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ"
|
|
||||||
f"?{urlencode(params)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_citywide_url(self, page: int = 1) -> str:
|
def _build_citywide_url(self, page: int = 1, *, secondary: bool = False) -> str:
|
||||||
"""T6: URL всего города (по умолчанию ЕКБ) без geo-фильтра, сортировка по дате.
|
"""T6: URL всего города (по умолчанию ЕКБ) без geo-фильтра, сортировка по дате.
|
||||||
|
|
||||||
Avito отдаёт все объявления города (cap ~5000 = 100 страниц × 50 карточек).
|
Avito отдаёт все объявления города (cap ~5000 = 100 страниц × 50 карточек).
|
||||||
|
secondary=True — путь вторички (#3033): cap тот же, но выдача в 5 раз уже.
|
||||||
"""
|
"""
|
||||||
params = {"s": 104, "p": page}
|
params = {"s": 104, "p": page}
|
||||||
return (
|
return f"{self.base_url}{self._category_path(secondary=secondary)}?{urlencode(params)}"
|
||||||
f"{self.base_url}/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ"
|
|
||||||
f"?{urlencode(params)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _build_newbuilding_url(self, page: int = 1) -> str:
|
def _build_newbuilding_url(self, page: int = 1) -> str:
|
||||||
"""URL городской выборки только новостроек (novostroyka-filter), сортировка по дате.
|
"""URL городской выборки только новостроек (novostroyka-filter), сортировка по дате.
|
||||||
|
|
@ -1722,9 +1756,12 @@ class AvitoScraper(BaseScraper):
|
||||||
pages: int = 100,
|
pages: int = 100,
|
||||||
*,
|
*,
|
||||||
delay_override_sec: float | None = None,
|
delay_override_sec: float | None = None,
|
||||||
|
secondary_only: bool = True,
|
||||||
) -> list[ScrapedLot]:
|
) -> list[ScrapedLot]:
|
||||||
"""T6: Обход всего ЕКБ без geo-фильтра (citywide-mode), paginated.
|
"""T6: Обход всего ЕКБ без geo-фильтра (citywide-mode), paginated.
|
||||||
|
|
||||||
|
secondary_only (default True, #3033): путь вторички вместо общей выдачи.
|
||||||
|
|
||||||
Возвращает дедуплицированный список лотов (по source_id/source_url).
|
Возвращает дедуплицированный список лотов (по source_id/source_url).
|
||||||
Break-on-empty: останавливается когда страница отдаёт 0 карточек.
|
Break-on-empty: останавливается когда страница отдаёт 0 карточек.
|
||||||
Сохраняет весь anti-block pipeline (_fetch_serp_html: firewall-detect,
|
Сохраняет весь anti-block pipeline (_fetch_serp_html: firewall-detect,
|
||||||
|
|
@ -1740,7 +1777,7 @@ class AvitoScraper(BaseScraper):
|
||||||
"""
|
"""
|
||||||
all_lots = await self._paginate_sweep(
|
all_lots = await self._paginate_sweep(
|
||||||
pages,
|
pages,
|
||||||
self._build_citywide_url,
|
lambda page: self._build_citywide_url(page, secondary=secondary_only),
|
||||||
label="citywide",
|
label="citywide",
|
||||||
delay_override_sec=delay_override_sec,
|
delay_override_sec=delay_override_sec,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue