Merge remote-tracking branch 'forgejo/main' into fix/3034-avito-fingerprint
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m34s
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m34s
This commit is contained in:
commit
b3a39699d1
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 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
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
# shellcheck source=./lib-backup.sh
|
||||
source "$SCRIPT_DIR/lib-backup.sh"
|
||||
|
||||
# --- config (env-overridable) ---
|
||||
COMPOSE_DIR="${COMPOSE_DIR:-/opt/gendesign}"
|
||||
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
|
||||
# dump — a handful of roles legitimately
|
||||
# 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.
|
||||
[[ -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
|
||||
# (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.
|
||||
|
|
@ -198,3 +207,10 @@ remaining=( "$LOCAL_BACKUP_DIR"/gendesign_[0-9]*.sql.gz )
|
|||
remaining_globals=( "$LOCAL_BACKUP_DIR"/gendesign_globals_*.sql.gz )
|
||||
shopt -u nullglob
|
||||
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
|
||||
# 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_ENDPOINT=https://s3.ru-1.storage.selcloud.ru
|
||||
|
|
@ -28,6 +39,10 @@
|
|||
#S3_ACCESS_KEY=REPLACE_WITH_REAL_ACCESS_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) ---
|
||||
#KEEP=7 # how many recent local dumps to retain (applies to both
|
||||
# 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)..."
|
||||
ready=0
|
||||
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
|
||||
break
|
||||
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_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
|
||||
# #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
|
||||
|
||||
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_NAV_TIMEOUT_MS — таймаут page.goto в мс (default: 60000)
|
||||
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'ить
|
||||
через 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.
|
||||
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мс мало:
|
||||
# первый XHR иногда ловит `NetworkError when attempting to fetch resource` (anti-bot/
|
||||
# сетевой стек страницы ещё не готов). Лечился внешним retry (re-navigation ~30-45с/дом).
|
||||
|
|
@ -898,6 +919,138 @@ async def _do_fetch(
|
|||
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(
|
||||
provider: str,
|
||||
url: str,
|
||||
|
|
@ -950,6 +1103,17 @@ async def _fetch_once(
|
|||
if BROWSER_WAIT_MS > 0:
|
||||
await page.wait_for_timeout(BROWSER_WAIT_MS) # 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:
|
||||
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_ACCESS_KEY=...
|
||||
# S3_SECRET_KEY=...
|
||||
#
|
||||
# Детект пропущенного запуска (#2203): по успеху пишется sentinel-файл
|
||||
# (SENTINEL_FILE) — отдельный cron-запуск ops/check-backup-staleness.sh
|
||||
# следит за его возрастом и шлёт алерт, если он не свежел (см. заголовок
|
||||
# того скрипта — канал алертов и cron-строка). Сам этот скрипт не алертит,
|
||||
# только отмечает успех.
|
||||
|
||||
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}"
|
||||
KEEP="${KEEP:-7}" # сколько копий хранить
|
||||
PG_CONTAINER="${PG_CONTAINER:-tradein-postgres}"
|
||||
MIN_DUMP_BYTES="${MIN_DUMP_BYTES:-10240}" # 10 KiB floor — пустая/битая
|
||||
# схема заведомо меньше, живая
|
||||
# БД — на порядки больше.
|
||||
SENTINEL_FILE="${SENTINEL_FILE:-${BACKUP_DIR}/.last_success}"
|
||||
|
||||
# S3-переменные: свой env-файл, а если его нет — общий с main-бэкапом.
|
||||
if [[ -f /etc/default/tradein-backup ]]; then
|
||||
|
|
@ -40,8 +51,6 @@ elif [[ -f /etc/default/gendesign-backup ]]; then
|
|||
source /etc/default/gendesign-backup
|
||||
fi
|
||||
|
||||
log() { echo "[$(date -u +'%Y-%m-%dT%H:%M:%SZ')] $*"; }
|
||||
|
||||
# Проверка целостности сверх «не пустой»: битый посреди записи дамп (диск
|
||||
# кончился, OOM-kill, оборвался docker exec) может дать структурно валидный,
|
||||
# не крошечный .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)
|
||||
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 ' ')
|
||||
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)
|
||||
- Location (lat, lon, avito_location_id, metro_stations[], address_full)
|
||||
- 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
|
||||
- Domoteka 5 полей (owners_count, owners_at_least, last_owner_change_date,
|
||||
encumbrances_clean, registry_match)
|
||||
|
|
@ -860,6 +861,60 @@ def parse_detail_html(html: str, source_url: str) -> DetailEnrichment:
|
|||
|
||||
|
||||
# ── 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:
|
||||
"""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 (не затираем
|
||||
canonical из Houses Catalog Stage 2c); house_url — new-first additive (avito-owned
|
||||
поле, нет конкурирующего canonical-писателя).
|
||||
Остальные house-level поля (passenger_elevators, cargo_elevators, has_concierge,
|
||||
closed_yard, total_floors_house) по-прежнему опускаются — нет колонок в listings,
|
||||
canonical приходит из Houses Catalog Stage 2c (отдельный follow-up).
|
||||
House-level поля (has_concierge, closed_yard, total_floors_house, house_type)
|
||||
пишутся в houses ЧЕРЕЗ listings.house_id_fk вторым оператором (#3036), fill-only:
|
||||
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(
|
||||
text("""
|
||||
|
|
@ -952,6 +1012,8 @@ def save_detail_enrichment(db: Session, e: DetailEnrichment) -> bool:
|
|||
"house_catalog_url": e.house_catalog_url,
|
||||
},
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
_fill_house_params_from_detail(db, e)
|
||||
db.commit()
|
||||
found = result.rowcount > 0
|
||||
if not found:
|
||||
|
|
|
|||
|
|
@ -242,6 +242,11 @@ NOVOSTROYKA_SLUG = "novostroyka-ASgBAgICAkSSA8YQ5geOUg"
|
|||
# сегмент. Цена вопроса: вторичка — 20,6 % общей выдачи ЕКБ, остальное раньше качалось
|
||||
# и отбрасывалось после разбора (страницы и антибан-бюджет уже потрачены).
|
||||
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) ───────────────────────────────
|
||||
# Верхняя граница цены при первом рекурсивном делении (нет явного hi).
|
||||
|
|
@ -261,8 +266,22 @@ _AVITO_MIN_BRACKET = 50_000
|
|||
# Последний брекет ОТКРЫТ (hi=None → pmax не ставится) — ловит весь хвост люкса
|
||||
# без потолка; он крошечный (avito >50М ≈ 73), пагинируется напрямую без бисекции.
|
||||
_AVITO_PRICE_SEED_BRACKETS: list[tuple[int, int | None]] = get_price_seed_brackets()
|
||||
# Avito SERP показывает ~50 карточек на страницу.
|
||||
_AVITO_OFFERS_PER_PAGE = 50
|
||||
# Avito SERP отдаёт 60 карточек на страницу. Замер живьём 2026-08-21 через
|
||||
# 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:
|
||||
|
|
@ -829,9 +848,14 @@ class AvitoScraper(BaseScraper):
|
|||
*,
|
||||
pages: int = 1,
|
||||
delay_override_sec: float | None = None,
|
||||
secondary_only: bool = True,
|
||||
) -> list[ScrapedLot]:
|
||||
"""Найти объявления Авито вокруг (lat, lon) в radius_m метрах.
|
||||
|
||||
secondary_only (default True, #3033): путь вторички вместо общей выдачи — класс и
|
||||
есть парсер вторички, новостройки ходят своим fetch_newbuildings. До этого якорный
|
||||
city-sweep качал общую выдачу (в прогоне 4539: 41 vtorichka + 26 novostroyki).
|
||||
|
||||
Avito работает в км — конвертируем. Минимум 1 км.
|
||||
|
||||
pages=1 (default) — backward compat, одна страница (~50 lots).
|
||||
|
|
@ -848,7 +872,7 @@ class AvitoScraper(BaseScraper):
|
|||
|
||||
all_lots: list[ScrapedLot] = []
|
||||
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:
|
||||
html = await self._fetch_serp_html(url, page)
|
||||
except (AvitoBlockedError, AvitoRateLimitedError):
|
||||
|
|
@ -905,28 +929,38 @@ class AvitoScraper(BaseScraper):
|
|||
билдерах скоупил oblast city-sweep на ЕКБ, невзирая на geoCoords/фильтр)."""
|
||||
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 = {
|
||||
"geoCoords": f"{lat},{lon}",
|
||||
"radius": radius_km,
|
||||
"s": 104, # sort by date
|
||||
"p": page,
|
||||
}
|
||||
return (
|
||||
f"{self.base_url}/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ"
|
||||
f"?{urlencode(params)}"
|
||||
)
|
||||
return f"{self.base_url}{self._category_path(secondary=secondary)}?{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-фильтра, сортировка по дате.
|
||||
|
||||
Avito отдаёт все объявления города (cap ~5000 = 100 страниц × 50 карточек).
|
||||
secondary=True — путь вторички (#3033): cap тот же, но выдача в 5 раз уже.
|
||||
"""
|
||||
params = {"s": 104, "p": page}
|
||||
return (
|
||||
f"{self.base_url}/{self._city_seg()}/kvartiry/prodam-ASgBAgICAUSSA8YQ"
|
||||
f"?{urlencode(params)}"
|
||||
)
|
||||
return f"{self.base_url}{self._category_path(secondary=secondary)}?{urlencode(params)}"
|
||||
|
||||
def _build_newbuilding_url(self, page: int = 1) -> str:
|
||||
"""URL городской выборки только новостроек (novostroyka-filter), сортировка по дате.
|
||||
|
|
@ -1722,9 +1756,12 @@ class AvitoScraper(BaseScraper):
|
|||
pages: int = 100,
|
||||
*,
|
||||
delay_override_sec: float | None = None,
|
||||
secondary_only: bool = True,
|
||||
) -> list[ScrapedLot]:
|
||||
"""T6: Обход всего ЕКБ без geo-фильтра (citywide-mode), paginated.
|
||||
|
||||
secondary_only (default True, #3033): путь вторички вместо общей выдачи.
|
||||
|
||||
Возвращает дедуплицированный список лотов (по source_id/source_url).
|
||||
Break-on-empty: останавливается когда страница отдаёт 0 карточек.
|
||||
Сохраняет весь anti-block pipeline (_fetch_serp_html: firewall-detect,
|
||||
|
|
@ -1740,7 +1777,7 @@ class AvitoScraper(BaseScraper):
|
|||
"""
|
||||
all_lots = await self._paginate_sweep(
|
||||
pages,
|
||||
self._build_citywide_url,
|
||||
lambda page: self._build_citywide_url(page, secondary=secondary_only),
|
||||
label="citywide",
|
||||
delay_override_sec=delay_override_sec,
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue