commit ab3352f76587237bbb4b2f93e651426770f1b60f Author: Yutsuo Date: Sat Jul 18 22:34:58 2026 -0300 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aaea57d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dfimoveis_data \ No newline at end of file diff --git a/.venv/bin/Activate.ps1 b/.venv/bin/Activate.ps1 new file mode 100644 index 0000000..b49d77b --- /dev/null +++ b/.venv/bin/Activate.ps1 @@ -0,0 +1,247 @@ +<# +.Synopsis +Activate a Python virtual environment for the current PowerShell session. + +.Description +Pushes the python executable for a virtual environment to the front of the +$Env:PATH environment variable and sets the prompt to signify that you are +in a Python virtual environment. Makes use of the command line switches as +well as the `pyvenv.cfg` file values present in the virtual environment. + +.Parameter VenvDir +Path to the directory that contains the virtual environment to activate. The +default value for this is the parent of the directory that the Activate.ps1 +script is located within. + +.Parameter Prompt +The prompt prefix to display when this virtual environment is activated. By +default, this prompt is the name of the virtual environment folder (VenvDir) +surrounded by parentheses and followed by a single space (ie. '(.venv) '). + +.Example +Activate.ps1 +Activates the Python virtual environment that contains the Activate.ps1 script. + +.Example +Activate.ps1 -Verbose +Activates the Python virtual environment that contains the Activate.ps1 script, +and shows extra information about the activation as it executes. + +.Example +Activate.ps1 -VenvDir C:\Users\MyUser\Common\.venv +Activates the Python virtual environment located in the specified location. + +.Example +Activate.ps1 -Prompt "MyPython" +Activates the Python virtual environment that contains the Activate.ps1 script, +and prefixes the current prompt with the specified string (surrounded in +parentheses) while the virtual environment is active. + +.Notes +On Windows, it may be required to enable this Activate.ps1 script by setting the +execution policy for the user. You can do this by issuing the following PowerShell +command: + +PS C:\> Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser + +For more information on Execution Policies: +https://go.microsoft.com/fwlink/?LinkID=135170 + +#> +Param( + [Parameter(Mandatory = $false)] + [String] + $VenvDir, + [Parameter(Mandatory = $false)] + [String] + $Prompt +) + +<# Function declarations --------------------------------------------------- #> + +<# +.Synopsis +Remove all shell session elements added by the Activate script, including the +addition of the virtual environment's Python executable from the beginning of +the PATH variable. + +.Parameter NonDestructive +If present, do not remove this function from the global namespace for the +session. + +#> +function global:deactivate ([switch]$NonDestructive) { + # Revert to original values + + # The prior prompt: + if (Test-Path -Path Function:_OLD_VIRTUAL_PROMPT) { + Copy-Item -Path Function:_OLD_VIRTUAL_PROMPT -Destination Function:prompt + Remove-Item -Path Function:_OLD_VIRTUAL_PROMPT + } + + # The prior PYTHONHOME: + if (Test-Path -Path Env:_OLD_VIRTUAL_PYTHONHOME) { + Copy-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME -Destination Env:PYTHONHOME + Remove-Item -Path Env:_OLD_VIRTUAL_PYTHONHOME + } + + # The prior PATH: + if (Test-Path -Path Env:_OLD_VIRTUAL_PATH) { + Copy-Item -Path Env:_OLD_VIRTUAL_PATH -Destination Env:PATH + Remove-Item -Path Env:_OLD_VIRTUAL_PATH + } + + # Just remove the VIRTUAL_ENV altogether: + if (Test-Path -Path Env:VIRTUAL_ENV) { + Remove-Item -Path env:VIRTUAL_ENV + } + + # Just remove VIRTUAL_ENV_PROMPT altogether. + if (Test-Path -Path Env:VIRTUAL_ENV_PROMPT) { + Remove-Item -Path env:VIRTUAL_ENV_PROMPT + } + + # Just remove the _PYTHON_VENV_PROMPT_PREFIX altogether: + if (Get-Variable -Name "_PYTHON_VENV_PROMPT_PREFIX" -ErrorAction SilentlyContinue) { + Remove-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Scope Global -Force + } + + # Leave deactivate function in the global namespace if requested: + if (-not $NonDestructive) { + Remove-Item -Path function:deactivate + } +} + +<# +.Description +Get-PyVenvConfig parses the values from the pyvenv.cfg file located in the +given folder, and returns them in a map. + +For each line in the pyvenv.cfg file, if that line can be parsed into exactly +two strings separated by `=` (with any amount of whitespace surrounding the =) +then it is considered a `key = value` line. The left hand string is the key, +the right hand is the value. + +If the value starts with a `'` or a `"` then the first and last character is +stripped from the value before being captured. + +.Parameter ConfigDir +Path to the directory that contains the `pyvenv.cfg` file. +#> +function Get-PyVenvConfig( + [String] + $ConfigDir +) { + Write-Verbose "Given ConfigDir=$ConfigDir, obtain values in pyvenv.cfg" + + # Ensure the file exists, and issue a warning if it doesn't (but still allow the function to continue). + $pyvenvConfigPath = Join-Path -Resolve -Path $ConfigDir -ChildPath 'pyvenv.cfg' -ErrorAction Continue + + # An empty map will be returned if no config file is found. + $pyvenvConfig = @{ } + + if ($pyvenvConfigPath) { + + Write-Verbose "File exists, parse `key = value` lines" + $pyvenvConfigContent = Get-Content -Path $pyvenvConfigPath + + $pyvenvConfigContent | ForEach-Object { + $keyval = $PSItem -split "\s*=\s*", 2 + if ($keyval[0] -and $keyval[1]) { + $val = $keyval[1] + + # Remove extraneous quotations around a string value. + if ("'""".Contains($val.Substring(0, 1))) { + $val = $val.Substring(1, $val.Length - 2) + } + + $pyvenvConfig[$keyval[0]] = $val + Write-Verbose "Adding Key: '$($keyval[0])'='$val'" + } + } + } + return $pyvenvConfig +} + + +<# Begin Activate script --------------------------------------------------- #> + +# Determine the containing directory of this script +$VenvExecPath = Split-Path -Parent $MyInvocation.MyCommand.Definition +$VenvExecDir = Get-Item -Path $VenvExecPath + +Write-Verbose "Activation script is located in path: '$VenvExecPath'" +Write-Verbose "VenvExecDir Fullname: '$($VenvExecDir.FullName)" +Write-Verbose "VenvExecDir Name: '$($VenvExecDir.Name)" + +# Set values required in priority: CmdLine, ConfigFile, Default +# First, get the location of the virtual environment, it might not be +# VenvExecDir if specified on the command line. +if ($VenvDir) { + Write-Verbose "VenvDir given as parameter, using '$VenvDir' to determine values" +} +else { + Write-Verbose "VenvDir not given as a parameter, using parent directory name as VenvDir." + $VenvDir = $VenvExecDir.Parent.FullName.TrimEnd("\\/") + Write-Verbose "VenvDir=$VenvDir" +} + +# Next, read the `pyvenv.cfg` file to determine any required value such +# as `prompt`. +$pyvenvCfg = Get-PyVenvConfig -ConfigDir $VenvDir + +# Next, set the prompt from the command line, or the config file, or +# just use the name of the virtual environment folder. +if ($Prompt) { + Write-Verbose "Prompt specified as argument, using '$Prompt'" +} +else { + Write-Verbose "Prompt not specified as argument to script, checking pyvenv.cfg value" + if ($pyvenvCfg -and $pyvenvCfg['prompt']) { + Write-Verbose " Setting based on value in pyvenv.cfg='$($pyvenvCfg['prompt'])'" + $Prompt = $pyvenvCfg['prompt']; + } + else { + Write-Verbose " Setting prompt based on parent's directory's name. (Is the directory name passed to venv module when creating the virtual environment)" + Write-Verbose " Got leaf-name of $VenvDir='$(Split-Path -Path $venvDir -Leaf)'" + $Prompt = Split-Path -Path $venvDir -Leaf + } +} + +Write-Verbose "Prompt = '$Prompt'" +Write-Verbose "VenvDir='$VenvDir'" + +# Deactivate any currently active virtual environment, but leave the +# deactivate function in place. +deactivate -nondestructive + +# Now set the environment variable VIRTUAL_ENV, used by many tools to determine +# that there is an activated venv. +$env:VIRTUAL_ENV = $VenvDir + +if (-not $Env:VIRTUAL_ENV_DISABLE_PROMPT) { + + Write-Verbose "Setting prompt to '$Prompt'" + + # Set the prompt to include the env name + # Make sure _OLD_VIRTUAL_PROMPT is global + function global:_OLD_VIRTUAL_PROMPT { "" } + Copy-Item -Path function:prompt -Destination function:_OLD_VIRTUAL_PROMPT + New-Variable -Name _PYTHON_VENV_PROMPT_PREFIX -Description "Python virtual environment prompt prefix" -Scope Global -Option ReadOnly -Visibility Public -Value $Prompt + + function global:prompt { + Write-Host -NoNewline -ForegroundColor Green "($_PYTHON_VENV_PROMPT_PREFIX) " + _OLD_VIRTUAL_PROMPT + } + $env:VIRTUAL_ENV_PROMPT = $Prompt +} + +# Clear PYTHONHOME +if (Test-Path -Path Env:PYTHONHOME) { + Copy-Item -Path Env:PYTHONHOME -Destination Env:_OLD_VIRTUAL_PYTHONHOME + Remove-Item -Path Env:PYTHONHOME +} + +# Add the venv to the PATH +Copy-Item -Path Env:PATH -Destination Env:_OLD_VIRTUAL_PATH +$Env:PATH = "$VenvExecDir$([System.IO.Path]::PathSeparator)$Env:PATH" diff --git a/.venv/bin/activate b/.venv/bin/activate new file mode 100644 index 0000000..875e23d --- /dev/null +++ b/.venv/bin/activate @@ -0,0 +1,70 @@ +# This file must be used with "source bin/activate" *from bash* +# You cannot run it directly + +deactivate () { + # reset old environment variables + if [ -n "${_OLD_VIRTUAL_PATH:-}" ] ; then + PATH="${_OLD_VIRTUAL_PATH:-}" + export PATH + unset _OLD_VIRTUAL_PATH + fi + if [ -n "${_OLD_VIRTUAL_PYTHONHOME:-}" ] ; then + PYTHONHOME="${_OLD_VIRTUAL_PYTHONHOME:-}" + export PYTHONHOME + unset _OLD_VIRTUAL_PYTHONHOME + fi + + # Call hash to forget past commands. Without forgetting + # past commands the $PATH changes we made may not be respected + hash -r 2> /dev/null + + if [ -n "${_OLD_VIRTUAL_PS1:-}" ] ; then + PS1="${_OLD_VIRTUAL_PS1:-}" + export PS1 + unset _OLD_VIRTUAL_PS1 + fi + + unset VIRTUAL_ENV + unset VIRTUAL_ENV_PROMPT + if [ ! "${1:-}" = "nondestructive" ] ; then + # Self destruct! + unset -f deactivate + fi +} + +# unset irrelevant variables +deactivate nondestructive + +# on Windows, a path can contain colons and backslashes and has to be converted: +if [ "${OSTYPE:-}" = "cygwin" ] || [ "${OSTYPE:-}" = "msys" ] ; then + # transform D:\path\to\venv to /d/path/to/venv on MSYS + # and to /cygdrive/d/path/to/venv on Cygwin + export VIRTUAL_ENV=$(cygpath /home/f2256342/forge/webscraper/.venv) +else + # use the path as-is + export VIRTUAL_ENV=/home/f2256342/forge/webscraper/.venv +fi + +_OLD_VIRTUAL_PATH="$PATH" +PATH="$VIRTUAL_ENV/"bin":$PATH" +export PATH + +# unset PYTHONHOME if set +# this will fail if PYTHONHOME is set to the empty string (which is bad anyway) +# could use `if (set -u; : $PYTHONHOME) ;` in bash +if [ -n "${PYTHONHOME:-}" ] ; then + _OLD_VIRTUAL_PYTHONHOME="${PYTHONHOME:-}" + unset PYTHONHOME +fi + +if [ -z "${VIRTUAL_ENV_DISABLE_PROMPT:-}" ] ; then + _OLD_VIRTUAL_PS1="${PS1:-}" + PS1='(.venv) '"${PS1:-}" + export PS1 + VIRTUAL_ENV_PROMPT='(.venv) ' + export VIRTUAL_ENV_PROMPT +fi + +# Call hash to forget past commands. Without forgetting +# past commands the $PATH changes we made may not be respected +hash -r 2> /dev/null diff --git a/.venv/bin/activate.csh b/.venv/bin/activate.csh new file mode 100644 index 0000000..06e677d --- /dev/null +++ b/.venv/bin/activate.csh @@ -0,0 +1,27 @@ +# This file must be used with "source bin/activate.csh" *from csh*. +# You cannot run it directly. + +# Created by Davide Di Blasi . +# Ported to Python 3.3 venv by Andrew Svetlov + +alias deactivate 'test $?_OLD_VIRTUAL_PATH != 0 && setenv PATH "$_OLD_VIRTUAL_PATH" && unset _OLD_VIRTUAL_PATH; rehash; test $?_OLD_VIRTUAL_PROMPT != 0 && set prompt="$_OLD_VIRTUAL_PROMPT" && unset _OLD_VIRTUAL_PROMPT; unsetenv VIRTUAL_ENV; unsetenv VIRTUAL_ENV_PROMPT; test "\!:*" != "nondestructive" && unalias deactivate' + +# Unset irrelevant variables. +deactivate nondestructive + +setenv VIRTUAL_ENV /home/f2256342/forge/webscraper/.venv + +set _OLD_VIRTUAL_PATH="$PATH" +setenv PATH "$VIRTUAL_ENV/"bin":$PATH" + + +set _OLD_VIRTUAL_PROMPT="$prompt" + +if (! "$?VIRTUAL_ENV_DISABLE_PROMPT") then + set prompt = '(.venv) '"$prompt" + setenv VIRTUAL_ENV_PROMPT '(.venv) ' +endif + +alias pydoc python -m pydoc + +rehash diff --git a/.venv/bin/activate.fish b/.venv/bin/activate.fish new file mode 100644 index 0000000..1e37c13 --- /dev/null +++ b/.venv/bin/activate.fish @@ -0,0 +1,69 @@ +# This file must be used with "source /bin/activate.fish" *from fish* +# (https://fishshell.com/). You cannot run it directly. + +function deactivate -d "Exit virtual environment and return to normal shell environment" + # reset old environment variables + if test -n "$_OLD_VIRTUAL_PATH" + set -gx PATH $_OLD_VIRTUAL_PATH + set -e _OLD_VIRTUAL_PATH + end + if test -n "$_OLD_VIRTUAL_PYTHONHOME" + set -gx PYTHONHOME $_OLD_VIRTUAL_PYTHONHOME + set -e _OLD_VIRTUAL_PYTHONHOME + end + + if test -n "$_OLD_FISH_PROMPT_OVERRIDE" + set -e _OLD_FISH_PROMPT_OVERRIDE + # prevents error when using nested fish instances (Issue #93858) + if functions -q _old_fish_prompt + functions -e fish_prompt + functions -c _old_fish_prompt fish_prompt + functions -e _old_fish_prompt + end + end + + set -e VIRTUAL_ENV + set -e VIRTUAL_ENV_PROMPT + if test "$argv[1]" != "nondestructive" + # Self-destruct! + functions -e deactivate + end +end + +# Unset irrelevant variables. +deactivate nondestructive + +set -gx VIRTUAL_ENV /home/f2256342/forge/webscraper/.venv + +set -gx _OLD_VIRTUAL_PATH $PATH +set -gx PATH "$VIRTUAL_ENV/"bin $PATH + +# Unset PYTHONHOME if set. +if set -q PYTHONHOME + set -gx _OLD_VIRTUAL_PYTHONHOME $PYTHONHOME + set -e PYTHONHOME +end + +if test -z "$VIRTUAL_ENV_DISABLE_PROMPT" + # fish uses a function instead of an env var to generate the prompt. + + # Save the current fish_prompt function as the function _old_fish_prompt. + functions -c fish_prompt _old_fish_prompt + + # With the original prompt function renamed, we can override with our own. + function fish_prompt + # Save the return status of the last command. + set -l old_status $status + + # Output the venv prompt; color taken from the blue of the Python logo. + printf "%s%s%s" (set_color 4B8BBE) '(.venv) ' (set_color normal) + + # Restore the return status of the previous command. + echo "exit $old_status" | . + # Output the original/"old" prompt. + _old_fish_prompt + end + + set -gx _OLD_FISH_PROMPT_OVERRIDE "$VIRTUAL_ENV" + set -gx VIRTUAL_ENV_PROMPT '(.venv) ' +end diff --git a/.venv/bin/httpx b/.venv/bin/httpx new file mode 100644 index 0000000..3308d50 --- /dev/null +++ b/.venv/bin/httpx @@ -0,0 +1,8 @@ +#!/home/f2256342/forge/webscraper/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from httpx import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/idna b/.venv/bin/idna new file mode 100644 index 0000000..199ba6b --- /dev/null +++ b/.venv/bin/idna @@ -0,0 +1,8 @@ +#!/home/f2256342/forge/webscraper/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from idna.cli import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pip b/.venv/bin/pip new file mode 100644 index 0000000..be9d56d --- /dev/null +++ b/.venv/bin/pip @@ -0,0 +1,8 @@ +#!/home/f2256342/forge/webscraper/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pip3 b/.venv/bin/pip3 new file mode 100644 index 0000000..be9d56d --- /dev/null +++ b/.venv/bin/pip3 @@ -0,0 +1,8 @@ +#!/home/f2256342/forge/webscraper/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/pip3.12 b/.venv/bin/pip3.12 new file mode 100644 index 0000000..be9d56d --- /dev/null +++ b/.venv/bin/pip3.12 @@ -0,0 +1,8 @@ +#!/home/f2256342/forge/webscraper/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from pip._internal.cli.main import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/bin/playwright b/.venv/bin/playwright new file mode 100644 index 0000000..e6bb676 --- /dev/null +++ b/.venv/bin/playwright @@ -0,0 +1,8 @@ +#!/home/f2256342/forge/webscraper/.venv/bin/python3 +# -*- coding: utf-8 -*- +import re +import sys +from playwright.__main__ import main +if __name__ == '__main__': + sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0]) + sys.exit(main()) diff --git a/.venv/include/site/python3.12/greenlet/greenlet.h b/.venv/include/site/python3.12/greenlet/greenlet.h new file mode 100644 index 0000000..d02a16e --- /dev/null +++ b/.venv/include/site/python3.12/greenlet/greenlet.h @@ -0,0 +1,164 @@ +/* -*- indent-tabs-mode: nil; tab-width: 4; -*- */ + +/* Greenlet object interface */ + +#ifndef Py_GREENLETOBJECT_H +#define Py_GREENLETOBJECT_H + + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* This is deprecated and undocumented. It does not change. */ +#define GREENLET_VERSION "1.0.0" + +#ifndef GREENLET_MODULE +#define implementation_ptr_t void* +#endif + +typedef struct _greenlet { + PyObject_HEAD + PyObject* weakreflist; + PyObject* dict; + implementation_ptr_t pimpl; +} PyGreenlet; + +#define PyGreenlet_Check(op) (op && PyObject_TypeCheck(op, &PyGreenlet_Type)) + + +/* C API functions */ + +/* Total number of symbols that are exported */ +#define PyGreenlet_API_pointers 12 + +#define PyGreenlet_Type_NUM 0 +#define PyExc_GreenletError_NUM 1 +#define PyExc_GreenletExit_NUM 2 + +#define PyGreenlet_New_NUM 3 +#define PyGreenlet_GetCurrent_NUM 4 +#define PyGreenlet_Throw_NUM 5 +#define PyGreenlet_Switch_NUM 6 +#define PyGreenlet_SetParent_NUM 7 + +#define PyGreenlet_MAIN_NUM 8 +#define PyGreenlet_STARTED_NUM 9 +#define PyGreenlet_ACTIVE_NUM 10 +#define PyGreenlet_GET_PARENT_NUM 11 + +#ifndef GREENLET_MODULE +/* This section is used by modules that uses the greenlet C API */ +static void** _PyGreenlet_API = NULL; + +# define PyGreenlet_Type \ + (*(PyTypeObject*)_PyGreenlet_API[PyGreenlet_Type_NUM]) + +# define PyExc_GreenletError \ + ((PyObject*)_PyGreenlet_API[PyExc_GreenletError_NUM]) + +# define PyExc_GreenletExit \ + ((PyObject*)_PyGreenlet_API[PyExc_GreenletExit_NUM]) + +/* + * PyGreenlet_New(PyObject *args) + * + * greenlet.greenlet(run, parent=None) + */ +# define PyGreenlet_New \ + (*(PyGreenlet * (*)(PyObject * run, PyGreenlet * parent)) \ + _PyGreenlet_API[PyGreenlet_New_NUM]) + +/* + * PyGreenlet_GetCurrent(void) + * + * greenlet.getcurrent() + */ +# define PyGreenlet_GetCurrent \ + (*(PyGreenlet * (*)(void)) _PyGreenlet_API[PyGreenlet_GetCurrent_NUM]) + +/* + * PyGreenlet_Throw( + * PyGreenlet *greenlet, + * PyObject *typ, + * PyObject *val, + * PyObject *tb) + * + * g.throw(...) + */ +# define PyGreenlet_Throw \ + (*(PyObject * (*)(PyGreenlet * self, \ + PyObject * typ, \ + PyObject * val, \ + PyObject * tb)) \ + _PyGreenlet_API[PyGreenlet_Throw_NUM]) + +/* + * PyGreenlet_Switch(PyGreenlet *greenlet, PyObject *args) + * + * g.switch(*args, **kwargs) + */ +# define PyGreenlet_Switch \ + (*(PyObject * \ + (*)(PyGreenlet * greenlet, PyObject * args, PyObject * kwargs)) \ + _PyGreenlet_API[PyGreenlet_Switch_NUM]) + +/* + * PyGreenlet_SetParent(PyObject *greenlet, PyObject *new_parent) + * + * g.parent = new_parent + */ +# define PyGreenlet_SetParent \ + (*(int (*)(PyGreenlet * greenlet, PyGreenlet * nparent)) \ + _PyGreenlet_API[PyGreenlet_SetParent_NUM]) + +/* + * PyGreenlet_GetParent(PyObject* greenlet) + * + * return greenlet.parent; + * + * This could return NULL even if there is no exception active. + * If it does not return NULL, you are responsible for decrementing the + * reference count. + */ +# define PyGreenlet_GetParent \ + (*(PyGreenlet* (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_GET_PARENT_NUM]) + +/* + * deprecated, undocumented alias. + */ +# define PyGreenlet_GET_PARENT PyGreenlet_GetParent + +# define PyGreenlet_MAIN \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_MAIN_NUM]) + +# define PyGreenlet_STARTED \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_STARTED_NUM]) + +# define PyGreenlet_ACTIVE \ + (*(int (*)(PyGreenlet*)) \ + _PyGreenlet_API[PyGreenlet_ACTIVE_NUM]) + + + + +/* Macro that imports greenlet and initializes C API */ +/* NOTE: This has actually moved to ``greenlet._greenlet._C_API``, but we + keep the older definition to be sure older code that might have a copy of + the header still works. */ +# define PyGreenlet_Import() \ + { \ + _PyGreenlet_API = (void**)PyCapsule_Import("greenlet._C_API", 0); \ + } + +#endif /* GREENLET_MODULE */ + +#ifdef __cplusplus +} +#endif +#endif /* !Py_GREENLETOBJECT_H */ diff --git a/.venv/pyvenv.cfg b/.venv/pyvenv.cfg new file mode 100644 index 0000000..1679941 --- /dev/null +++ b/.venv/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /usr/bin +include-system-site-packages = false +version = 3.12.3 +executable = /usr/bin/python3.12 +command = /usr/bin/python3 -m venv /home/f2256342/forge/webscraper/.venv diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..870de41 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,191 @@ +# AGENTS.md — Busca de Imóveis DF + +## Finalidade + +Este repositório apoia a coleta, organização e análise histórica de anúncios imobiliários do Distrito Federal para uma decisão residencial de longo prazo. + +Antes de trabalhar no projeto, leia integralmente: + +- `docs/PROJECT_CONTEXT.md` +- `docs/PROPERTY_CRITERIA.md` +- `docs/DATA_MODEL.md` +- `docs/ANALYSIS_RULES.md` + +Esses documentos são a referência operacional do projeto. Quando houver conflito: + +1. uma instrução explícita e recente do usuário prevalece; +2. depois, prevalecem estes documentos; +3. por último, hipóteses inferidas a partir dos dados. + +Nunca trate uma hipótese como preferência confirmada. + +Para auxiliar no entendimento e contexto, as plantas das casas dos Jardins Mangueiral estão em: + +- `resources/plans` + +Para auxiliar no entendimento e contexto, foram feitas capturas dos mapas dos Jardins Mangueiral, que foram salvas na em: + +- `resources/maps` + +--- + +## Banco de dados + +O banco principal deve ser tratado como **estritamente somente leitura**. + +### Regras obrigatórias + +- Nunca execute `INSERT`, `UPDATE`, `DELETE`, `DROP`, `ALTER`, `CREATE`, `REPLACE`, `VACUUM`, `REINDEX`, `ATTACH` ou comandos equivalentes contra o banco original. +- Não substitua, mova, compacte, migre ou renomeie o arquivo original. +- Não remova arquivos `-wal` ou `-shm` sem uma instrução explícita. +- Não execute scripts de scraping contra o banco durante uma análise sem autorização. +- Use `LIMIT` em consultas exploratórias. +- Não imprima tabelas inteiras no terminal. +- Prefira agregações, amostras pequenas e exportações derivadas. +- Salve resultados em `reports/`, `exports/` ou `data/derived/`. +- Nunca grave resultados derivados dentro do banco original. +- Antes de qualquer análise, confirme o caminho do banco e abra-o em modo somente leitura. + +### Abertura recomendada em Python + +```python +import sqlite3 + +DB_PATH = "data/imoveis.sqlite" + +connection = sqlite3.connect( + f"file:{DB_PATH}?mode=ro&immutable=1", + uri=True, +) +connection.execute("PRAGMA query_only = ON") +``` + +Se o banco estiver sendo atualizado por outro processo, não use `immutable=1`; mantenha `mode=ro` e confirme a consistência do snapshot antes de analisar. + +### Cliente de linha de comando + +```bash +sqlite3 -readonly data/imoveis.sqlite +``` + +--- + +## Fluxo inicial obrigatório + +Ao iniciar uma nova tarefa de análise: + +1. Identifique o arquivo SQLite correto e seu tamanho. +2. Leia `sqlite_master` para listar tabelas, views, índices e triggers. +3. Examine o esquema real antes de escrever consultas analíticas. +4. Conte registros por tabela. +5. Identifique campos de data, preço, área, quartos, localização, URL, anunciante e identificadores. +6. Verifique valores nulos, duplicidades, formatos inconsistentes e possíveis quebras de coleta. +7. Descubra se os dados representam: + - anúncios; + - imóveis físicos; + - observações históricas do mesmo anúncio; + - execuções do scraper. +8. Documente qualquer ambiguidade antes de produzir conclusões. + +Não presuma que o modelo lógico descrito em `docs/DATA_MODEL.md` já existe fisicamente. + +--- + +## Princípios de análise + +- Diferencie sempre **anúncio**, **imóvel físico** e **observação histórica**. +- Um mesmo imóvel pode aparecer em vários anúncios, portais, corretores ou datas. +- Um mesmo anúncio pode mudar de preço, descrição, área informada ou status. +- Preço anunciado não é preço efetivamente negociado. +- Ausência de anúncio não prova venda. +- Remoção de anúncio não prova fechamento de negócio. +- Campo preenchido pelo anunciante não deve ser considerado confiável sem validação. +- Não calcule preço por metro quadrado quando a área não for comparável ou confiável. +- Não misture área privativa, útil, construída e total sem separar os conceitos. +- Não compare períodos sem verificar mudanças no universo coletado. +- Não compare regiões sem controlar tipologia, quartos, área, garagem, estado e padrão do imóvel. +- Sempre informe tamanho da amostra, cobertura temporal, filtros e limitações. +- Prefira mediana, percentis e intervalos interquartis a médias isoladas. +- Mostre a sensibilidade das conclusões a outliers, duplicidades e dados ausentes. + +--- + +## Segurança e desempenho + +- Nunca exponha credenciais, cookies, tokens, chaves, sessões ou dados pessoais em relatórios. +- Não envie o banco ou grandes amostras para serviços externos sem autorização explícita. +- Não tente contornar mecanismos de proteção de sites. +- Respeite limites de requisição, termos aplicáveis e regras definidas para o scraper. +- Evite consultas que façam varreduras desnecessárias repetidas em tabelas grandes. +- Use `EXPLAIN QUERY PLAN` em consultas lentas. +- Sugira índices apenas como recomendação; não os crie no banco original. +- Para transformações pesadas, crie um banco derivado ou arquivos Parquet/CSV separados. + +--- + +## Código + +- Escreva scripts reproduzíveis, parametrizados e idempotentes. +- Use caminhos relativos ao repositório. +- Evite constantes escondidas. +- Registre os filtros e premissas usados. +- Inclua tratamento de erros. +- Use consultas SQL parametrizadas. +- Adicione testes para regras de normalização, deduplicação e cálculo. +- Não altere arquivos sem relação com a tarefa. +- Não faça refatorações amplas sem necessidade. +- Não invente significado para colunas pouco claras; documente a dúvida. + +--- + +## Saídas esperadas + +Relatórios analíticos devem, sempre que aplicável, conter: + +1. pergunta respondida; +2. período e universo analisados; +3. filtros e exclusões; +4. tamanho da amostra; +5. método de deduplicação; +6. qualidade dos dados; +7. resultados; +8. limitações; +9. implicações para a decisão de compra; +10. consultas ou scripts usados para reproduzir o resultado. + +Use gráficos apenas quando eles melhorarem a interpretação. Todo gráfico deve ter título, unidade, período, tamanho da amostra e definição da métrica. + +--- + +## Contexto imobiliário prioritário + +As regiões prioritárias são: + +1. Cruzeiro, especialmente Cruzeiro Novo; +2. Jardins Mangueiral. + +Não exclua outras regiões de uma análise exploratória quando forem úteis como referência, mas não desvie o projeto de seu objetivo principal sem instrução. + +Para Jardins Mangueiral: + +- considerar somente casas com planta original de três quartos; +- não penalizar automaticamente o fechamento do quintal; +- avaliar a funcionalidade real da conversão; +- considerar as preferências de QC descritas em `docs/PROPERTY_CRITERIA.md`; +- tratar relatos de moradores como evidência anedótica, não como fato conclusivo. + +--- + +## Atualização do contexto + +Não altere estes documentos apenas porque encontrou um padrão no banco. + +Atualize-os somente quando: + +- o usuário corrigir uma premissa; +- uma preferência for explicitamente confirmada; +- um valor financeiro for atualizado; +- uma regra metodológica for aprovada; +- o esquema real do projeto for consolidado. + +Ao atualizar uma premissa financeira, registre a data de referência e preserve o histórico em Git. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0d38a33 --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ +# Busca de Imóveis DF + +Projeto pessoal para coletar, organizar e analisar anúncios imobiliários do Distrito Federal, com foco em uma decisão residencial de longo prazo. + +As regiões prioritárias são: + +1. Cruzeiro, especialmente Cruzeiro Novo; +2. Jardins Mangueiral. + +O objetivo não é identificar simplesmente o menor preço anunciado. A análise considera funcionalidade, estado do imóvel, risco de reforma, localização, liquidez, custos de aquisição e manutenção e adequação à vida familiar. + +## Estado atual + +O repositório contém: + +- scraper autorizado para o portal DF Imóveis; +- banco SQLite com anúncios, snapshots de conteúdo, execuções de coleta e fotos; +- HTMLs brutos para reextração e auditoria; +- plantas e mapas de apoio do Jardins Mangueiral; +- relatórios reproduzíveis para priorização de visitas. + +Relatórios principais: + +- [Top 10 — Cruzeiro Novo](reports/cruzeiro_top10/README.md) +- [Top 10 — Jardins Mangueiral](reports/mangueiral_top10/README.md) +- [Cruzeiro Novo x Jardins Mangueiral](reports/cruzeiro_vs_mangueiral/README.md) + +Os rankings são prioridades de investigação, não recomendações automáticas de compra nem estimativas de preço negociado. + +## Princípios metodológicos + +- Diferenciar anúncio, snapshot histórico e imóvel físico provável. +- Tratar preço como valor pedido, não como preço de transação. +- Não interpretar retirada de anúncio como prova de venda. +- Deduplicar republicações e anúncios do mesmo imóvel de forma reversível. +- Não calcular preço por metro quadrado quando o conceito de área não for comparável. +- Considerar fotos, descrições e HTML bruto, sem assumir que confirmam estrutura ou documentação. +- Excluir do ranking de visitas anúncios cuja primeira foto indique “vendido” ou “negócio fechado”, registrando-os em quarentena visual. +- Para o Jardins Mangueiral, considerar somente casas com planta original provável de três quartos. +- Não penalizar automaticamente quintais fechados; avaliar sua funcionalidade, ventilação e qualidade construtiva. + +As regras completas estão em [docs/ANALYSIS_RULES.md](docs/ANALYSIS_RULES.md) e [docs/PROPERTY_CRITERIA.md](docs/PROPERTY_CRITERIA.md). + +## Estrutura do repositório + +```text +. +├── dfimoveis_scraper.py # coletor principal +├── search_list.txt # buscas executadas pelo scraper +├── requirements-dfimoveis.txt # dependências Python +├── dfimoveis_data/ # banco, fotos, HTML bruto e perfil do navegador +├── docs/ # contexto, critérios, modelo e regras +├── reports/ # relatórios e artefatos reproduzíveis +└── resources/ + ├── maps/ # capturas de mapas do Mangueiral + └── plans/ # plantas de referência de casas de dois e três quartos +``` + +Documentação essencial: + +- [PROJECT_CONTEXT.md](docs/PROJECT_CONTEXT.md): objetivo, contexto residencial e premissas financeiras; +- [PROPERTY_CRITERIA.md](docs/PROPERTY_CRITERIA.md): critérios funcionais e de visita; +- [DATA_MODEL.md](docs/DATA_MODEL.md): modelo conceitual desejado; +- [ACTUAL_SCHEMA.md](docs/ACTUAL_SCHEMA.md): esquema físico observado; +- [ANALYSIS_RULES.md](docs/ANALYSIS_RULES.md): regras metodológicas; +- [AGENTS.md](AGENTS.md): instruções operacionais e de segurança. + +## Requisitos + +- Python 3.12 ou posterior; +- Google Chrome ou Microsoft Edge para a coleta assistida por navegador; +- PowerShell para os exemplos abaixo. + +Crie um ambiente virtual e instale as dependências: + +```powershell +python -m venv .venv-scraper +.\.venv-scraper\Scripts\python.exe -m pip install -r requirements-dfimoveis.txt +``` + +As ferramentas de similaridade fotográfica usam Pillow, incluído no arquivo de requisitos. + +## Configuração das buscas + +O arquivo [search_list.txt](search_list.txt) contém uma URL completa por linha. Atualmente ele cobre: + +- apartamentos de três quartos no Cruzeiro Novo; +- casas anunciadas com três ou quatro quartos no Jardins Mangueiral. + +A busca do Mangueiral inclui quatro quartos para ampliar a coleta, mas esses imóveis são excluídos da análise regional. Mesmo três quartos anunciados não comprovam a planta original. + +## Executando o scraper + +Use apenas em sites e condições para os quais você tenha autorização. Respeite termos aplicáveis, limites de requisição e mecanismos de proteção do portal. + +### Navegador iniciado pelo scraper + +```powershell +.\.venv-scraper\Scripts\python.exe .\dfimoveis_scraper.py ` + --search-file .\search_list.txt ` + --output .\dfimoveis_data +``` + +### Chrome iniciado manualmente + +Quando necessário, inicie uma instância separada do Chrome com depuração remota. Não use o perfil cotidiano do navegador. + +```powershell +& "C:\Program Files\Google\Chrome\Application\chrome.exe" ` + --remote-debugging-port=9222 ` + --user-data-dir="E:\forge\python\house-quest\dfimoveis_data\browser-profile" +``` + +Em outro terminal: + +```powershell +.\.venv-scraper\Scripts\python.exe .\dfimoveis_scraper.py ` + --search-file .\search_list.txt ` + --output .\dfimoveis_data ` + --attach-chrome +``` + +Consulte todas as opções com: + +```powershell +.\.venv-scraper\Scripts\python.exe .\dfimoveis_scraper.py --help +``` + +Não execute o scraper enquanto uma análise espera um snapshot estável do banco. + +## Banco de dados + +O banco principal fica em: + +```text +dfimoveis_data/dfimoveis.sqlite3 +``` + +Ele deve ser tratado como estritamente somente leitura durante análises. Nunca grave resultados derivados dentro dele. + +Quando não houver processo de coleta e não existir WAL relevante: + +```python +import sqlite3 + +connection = sqlite3.connect( + "file:dfimoveis_data/dfimoveis.sqlite3?mode=ro&immutable=1", + uri=True, +) +connection.execute("PRAGMA query_only = ON") +``` + +Quando existir `dfimoveis.sqlite3-wal` ou houver possibilidade de atualização concorrente, não use `immutable=1`: + +```python +connection = sqlite3.connect( + "file:dfimoveis_data/dfimoveis.sqlite3?mode=ro", + uri=True, +) +connection.execute("PRAGMA query_only = ON") +connection.execute("BEGIN") +``` + +Não remova arquivos `-wal` ou `-shm`. Consulte [ACTUAL_SCHEMA.md](docs/ACTUAL_SCHEMA.md) antes de escrever consultas analíticas. + +## Reproduzindo os relatórios + +Cada relatório regional contém: + +```text +reports// +├── README.md +├── analysis.py +├── photo_similarity.py +├── query.sql +├── parameters.json +└── results.csv +``` + +Extração tabular somente leitura: + +```powershell +.\.venv-scraper\Scripts\python.exe .\reports\cruzeiro_top10\analysis.py +.\.venv-scraper\Scripts\python.exe .\reports\mangueiral_top10\analysis.py +``` + +Triagem de possíveis duplicatas por similaridade visual: + +```powershell +.\.venv-scraper\Scripts\python.exe .\reports\cruzeiro_top10\photo_similarity.py +.\.venv-scraper\Scripts\python.exe .\reports\mangueiral_top10\photo_similarity.py +``` + +Similaridade de imagem é apenas um sinal para revisão humana, nunca prova automática de que dois anúncios representam o mesmo imóvel. + +## Limitações + +- A cobertura atual usa um único portal e poucas datas de coleta. +- O banco representa anúncios e mudanças de conteúdo, não transações imobiliárias. +- Localização e campos de anunciante apresentam contaminação de extração documentada. +- A área estruturada não informa de forma segura se é útil, privativa, construída, total ou de terreno. +- Fotos podem conter material genérico, itens de outros anúncios e selos de venda. +- Fotos não confirmam documentação, sistemas elétricos e hidráulicos, impermeabilização, ruído ou conforto térmico. +- A situação financeira registrada deve ser atualizada antes de proposta, financiamento ou definição de preço máximo. + +## Segurança e privacidade + +- Não publique credenciais, cookies, tokens, sessões ou perfis do navegador. +- Não envie o banco ou grandes amostras a serviços externos sem autorização. +- Não tente contornar mecanismos de proteção de sites. +- O diretório `dfimoveis_data/` é ignorado pelo Git e deve permanecer local. +- Revise qualquer artefato derivado antes de versioná-lo para evitar dados pessoais. + +## Finalidade + +Este projeto apoia uma decisão pessoal de compra residencial. Ele não constitui avaliação imobiliária profissional, parecer jurídico, inspeção de engenharia ou recomendação financeira. diff --git a/__pycache__/dfimoveis_scraper.cpython-312.pyc b/__pycache__/dfimoveis_scraper.cpython-312.pyc new file mode 100644 index 0000000..4ef117e Binary files /dev/null and b/__pycache__/dfimoveis_scraper.cpython-312.pyc differ diff --git a/__pycache__/dfimoveis_scraper.cpython-314.pyc b/__pycache__/dfimoveis_scraper.cpython-314.pyc new file mode 100644 index 0000000..97f8133 Binary files /dev/null and b/__pycache__/dfimoveis_scraper.cpython-314.pyc differ diff --git a/dfimoveis_scraper.py b/dfimoveis_scraper.py new file mode 100644 index 0000000..27b697a --- /dev/null +++ b/dfimoveis_scraper.py @@ -0,0 +1,1215 @@ +#!/usr/bin/env python3 +"""Authorized scraper for dfimoveis.com.br. + +Features: +- Multiple search URLs +- pagina=N pagination +- Async bounded HTTP requests with retries and jitter +- Listing detail extraction from JSON-LD, OpenGraph and HTML text +- Full image URL discovery and concurrent photo downloads +- SQLite persistence, change history, raw HTML snapshots and resumability +- Playwright (preferring the Patchright driver, if installed, to avoid + Cloudflare's CDP-leak detection) driving real Google Chrome by default +- Automatic Cloudflare-challenge handling: broader challenge detection, a + persistent browser profile that keeps its clearance cookie across runs, and + a fallback that reuses the browser session's cookies for plain HTTP and + photo requests so they don't have to re-solve the challenge themselves +- Optional direct HTTP-only mode + +Python 3.11+ +""" +from __future__ import annotations + +import argparse +import asyncio +import contextlib +import dataclasses +import datetime as dt +import gzip +import hashlib +import html +import json +import logging +import mimetypes +import os +import random +import re +import sqlite3 +import sys +import time +from pathlib import Path +from typing import Any, Iterable +from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse + +import requests +from bs4 import BeautifulSoup + +BASE_URL = "https://www.dfimoveis.com.br" +DEFAULT_USER_AGENT = "AuthorizedDFImoveisResearchBot/1.0 (+contact@example.com)" +LISTING_HREF_RE = re.compile(r"^/imovel/[^?#]+-(\d+)(?:[/?#]|$)", re.I) +ABS_LISTING_RE = re.compile(r"https?://(?:www\.)?dfimoveis\.com\.br/imovel/[^\"'<>\\s]+?-(\d+)(?:[/?#]|$)", re.I) +IMAGE_URL_RE = re.compile( + r"https?://[^\"'<>\\s]+?\.(?:jpe?g|png|webp|avif)(?:\?[^\"'<>\\s]*)?", + re.I, +) +MONEY_RE = re.compile(r"R\$\s*([\d.]+(?:,\d{1,2})?)") +AREA_RE = re.compile(r"([\d.,]+)\s*m[²2]", re.I) +NUMERIC_ID_RE = re.compile(r"-(\d+)(?:[/?#]|$)") + + +def now_utc() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds") + + +def clean_text(value: Any) -> str | None: + if value is None: + return None + text = re.sub(r"\s+", " ", html.unescape(str(value))).strip() + return text or None + + +def parse_brl(value: str | None) -> float | None: + if not value: + return None + m = MONEY_RE.search(value) + raw = m.group(1) if m else value + raw = re.sub(r"[^\d,.]", "", raw) + if not raw: + return None + if "," in raw: + raw = raw.replace(".", "").replace(",", ".") + else: + parts = raw.split(".") + if len(parts) > 1 and all(len(p) == 3 for p in parts[1:]): + raw = "".join(parts) + with contextlib.suppress(ValueError): + return float(raw) + return None + + +def parse_number(value: str | None) -> float | None: + if not value: + return None + raw = re.sub(r"[^\d,.]", "", value) + if not raw: + return None + if "," in raw: + raw = raw.replace(".", "").replace(",", ".") + with contextlib.suppress(ValueError): + return float(raw) + return None + + +def int_near_label(text: str, labels: Iterable[str]) -> int | None: + for label in labels: + patterns = [ + rf"(\d+)\s*{label}", + rf"{label}\s*[:\-]?\s*(\d+)", + ] + for pattern in patterns: + m = re.search(pattern, text, re.I) + if m: + return int(m.group(1)) + return None + + +def canonical_url(url: str) -> str: + p = urlparse(urljoin(BASE_URL, url)) + return urlunparse((p.scheme or "https", p.netloc.lower(), p.path.rstrip("/"), "", p.query, "")) + + +def page_url(search_url: str, page: int) -> str: + p = urlparse(search_url) + q = dict(parse_qsl(p.query, keep_blank_values=True)) + q["pagina"] = str(page) + return urlunparse((p.scheme, p.netloc, p.path, p.params, urlencode(q, doseq=True), "")) + + +def listing_id_from_url(url: str) -> str | None: + m = NUMERIC_ID_RE.search(urlparse(url).path) + return m.group(1) if m else None + + +def sha256_bytes(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def safe_name(value: str) -> str: + return re.sub(r"[^a-zA-Z0-9._-]+", "_", value).strip("_")[:120] or "unnamed" + + +@dataclasses.dataclass(slots=True) +class Config: + searches: list[str] + output_dir: Path + database: Path + max_pages: int = 0 + concurrency: int = 2 + photo_concurrency: int = 2 + delay_min: float = 3.0 + delay_max: float = 6.0 + cooldown_base: float = 30.0 + cooldown_max: float = 300.0 + timeout: float = 30.0 + retries: int = 4 + download_photos: bool = True + save_raw_html: bool = True + use_playwright: bool = True + playwright_headless: bool = False + user_agent: str = DEFAULT_USER_AGENT + browser_user_agent: str | None = None + browser_channel: str = "chrome" + attach_chrome: bool = False + cdp_url: str = "http://localhost:9222" + cloudflare_fallback: bool = True + challenge_timeout: float = 180.0 + + +class Database: + def __init__(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + self.conn = sqlite3.connect(path) + self.conn.row_factory = sqlite3.Row + self.conn.execute("PRAGMA journal_mode=WAL") + self.conn.execute("PRAGMA foreign_keys=ON") + self._init_schema() + + def _init_schema(self) -> None: + self.conn.executescript( + """ + CREATE TABLE IF NOT EXISTS listings ( + listing_id TEXT PRIMARY KEY, + url TEXT NOT NULL, + title TEXT, + description TEXT, + transaction_type TEXT, + property_type TEXT, + price_brl REAL, + condominium_brl REAL, + iptu_brl REAL, + area_m2 REAL, + bedrooms INTEGER, + suites INTEGER, + parking_spaces INTEGER, + address TEXT, + neighborhood TEXT, + city TEXT, + state TEXT, + advertiser_name TEXT, + advertiser_code TEXT, + creci TEXT, + latitude REAL, + longitude REAL, + published_at TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + inactive_at TEXT, + content_hash TEXT, + raw_html_path TEXT, + extra_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE IF NOT EXISTS listing_searches ( + listing_id TEXT NOT NULL, + search_url TEXT NOT NULL, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + PRIMARY KEY (listing_id, search_url), + FOREIGN KEY (listing_id) REFERENCES listings(listing_id) + ); + CREATE TABLE IF NOT EXISTS listing_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + listing_id TEXT NOT NULL, + captured_at TEXT NOT NULL, + content_hash TEXT NOT NULL, + data_json TEXT NOT NULL, + UNIQUE(listing_id, content_hash), + FOREIGN KEY (listing_id) REFERENCES listings(listing_id) + ); + CREATE TABLE IF NOT EXISTS photos ( + listing_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + source_url TEXT NOT NULL, + sha256 TEXT, + mime_type TEXT, + bytes INTEGER, + local_path TEXT, + first_seen_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + PRIMARY KEY (listing_id, source_url), + FOREIGN KEY (listing_id) REFERENCES listings(listing_id) + ); + CREATE TABLE IF NOT EXISTS crawl_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + started_at TEXT NOT NULL, + finished_at TEXT, + status TEXT NOT NULL, + stats_json TEXT NOT NULL DEFAULT '{}' + ); + """ + ) + self.conn.commit() + + def start_run(self) -> int: + cur = self.conn.execute( + "INSERT INTO crawl_runs(started_at,status) VALUES(?,?)", (now_utc(), "running") + ) + self.conn.commit() + return int(cur.lastrowid) + + def finish_run(self, run_id: int, status: str, stats: dict[str, Any]) -> None: + self.conn.execute( + "UPDATE crawl_runs SET finished_at=?, status=?, stats_json=? WHERE id=?", + (now_utc(), status, json.dumps(stats, ensure_ascii=False), run_id), + ) + self.conn.commit() + + def upsert_listing(self, data: dict[str, Any], search_url: str) -> bool: + ts = now_utc() + existing = self.conn.execute( + "SELECT content_hash FROM listings WHERE listing_id=?", (data["listing_id"],) + ).fetchone() + changed = not existing or existing["content_hash"] != data["content_hash"] + cols = [ + "listing_id", "url", "title", "description", "transaction_type", "property_type", + "price_brl", "condominium_brl", "iptu_brl", "area_m2", "bedrooms", "suites", + "parking_spaces", "address", "neighborhood", "city", "state", "advertiser_name", + "advertiser_code", "creci", "latitude", "longitude", "published_at", "content_hash", + "raw_html_path", "extra_json" + ] + values = [data.get(c) for c in cols] + self.conn.execute( + f""" + INSERT INTO listings ({','.join(cols)}, first_seen_at, last_seen_at, inactive_at) + VALUES ({','.join('?' for _ in cols)}, ?, ?, NULL) + ON CONFLICT(listing_id) DO UPDATE SET + {','.join(f'{c}=excluded.{c}' for c in cols if c != 'listing_id')}, + last_seen_at=excluded.last_seen_at, + inactive_at=NULL + """, + (*values, ts, ts), + ) + self.conn.execute( + """ + INSERT INTO listing_searches(listing_id,search_url,first_seen_at,last_seen_at) + VALUES(?,?,?,?) + ON CONFLICT(listing_id,search_url) DO UPDATE SET last_seen_at=excluded.last_seen_at + """, + (data["listing_id"], search_url, ts, ts), + ) + if changed: + snapshot = {k: v for k, v in data.items() if k not in {"raw_html_path"}} + self.conn.execute( + "INSERT OR IGNORE INTO listing_history(listing_id,captured_at,content_hash,data_json) VALUES(?,?,?,?)", + (data["listing_id"], ts, data["content_hash"], json.dumps(snapshot, ensure_ascii=False)), + ) + self.conn.commit() + return changed + + def upsert_photo(self, listing_id: str, ordinal: int, url: str, **meta: Any) -> None: + ts = now_utc() + self.conn.execute( + """ + INSERT INTO photos(listing_id,ordinal,source_url,sha256,mime_type,bytes,local_path,first_seen_at,last_seen_at) + VALUES(?,?,?,?,?,?,?,?,?) + ON CONFLICT(listing_id,source_url) DO UPDATE SET + ordinal=excluded.ordinal, sha256=COALESCE(excluded.sha256,photos.sha256), + mime_type=COALESCE(excluded.mime_type,photos.mime_type), + bytes=COALESCE(excluded.bytes,photos.bytes), + local_path=COALESCE(excluded.local_path,photos.local_path), last_seen_at=excluded.last_seen_at + """, + (listing_id, ordinal, url, meta.get("sha256"), meta.get("mime_type"), meta.get("bytes"), meta.get("local_path"), ts, ts), + ) + self.conn.commit() + + def get_photo(self, listing_id: str, url: str) -> sqlite3.Row | None: + return self.conn.execute( + "SELECT sha256, local_path FROM photos WHERE listing_id=? AND source_url=?", + (listing_id, url), + ).fetchone() + + def mark_inactive(self, search_url: str, seen_ids: set[str], run_started_at: str) -> int: + rows = self.conn.execute( + "SELECT listing_id FROM listing_searches WHERE search_url=? AND last_seen_at < ?", + (search_url, run_started_at), + ).fetchall() + stale = [r[0] for r in rows if r[0] not in seen_ids] + if stale: + self.conn.executemany( + "UPDATE listings SET inactive_at=COALESCE(inactive_at, ?) WHERE listing_id=?", + [(now_utc(), x) for x in stale], + ) + self.conn.commit() + return len(stale) + + def close(self) -> None: + self.conn.close() + + +@dataclasses.dataclass(slots=True) +class _BrowserResponse: + """Duck-types the subset of requests.Response used elsewhere in this file, + so a fetch made through the Playwright browser context (see + Fetcher._resolve_via_browser) can be returned interchangeably with a plain + requests.Session response.""" + + status_code: int + headers: requests.structures.CaseInsensitiveDict + text: str + content: bytes + url: str + + def raise_for_status(self) -> None: + if self.status_code >= 400: + raise requests.exceptions.HTTPError(f"HTTP {self.status_code}", response=self) + + +class Fetcher: + def __init__(self, cfg: Config) -> None: + self.cfg = cfg + self.sem = asyncio.Semaphore(cfg.concurrency) + self.photo_sem = asyncio.Semaphore(cfg.photo_concurrency) + self._playwright = None + self._browser_context = None + self._owns_browser_context = True + self._page = None + self._browser_lock = asyncio.Lock() + self._rate_lock = asyncio.Lock() + self._next_request_at = 0.0 + self._cooldown_until = 0.0 + self._consecutive_429 = 0 + # requests.Session is synchronous, so blocking calls are pushed onto + # worker threads with asyncio.to_thread below. + self.session = requests.Session() + self.session.headers.update( + { + "User-Agent": cfg.user_agent, + "Accept-Language": "pt-BR,pt;q=0.9,en;q=0.7", + "Accept": "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8", + # A closer match to a real browser's request fingerprint makes + # Cloudflare's heuristic (non-challenge) checks less likely to fire. + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", + } + ) + + async def close(self) -> None: + if self._browser_context is not None: + if self._owns_browser_context: + try: + await self._browser_context.close() + except Exception as exc: + # The operator may close Chrome while handling a Cloudflare + # challenge, or the browser process may already have exited. + # Cleanup must not hide the exception that caused the scraper + # to stop. + logging.debug("Browser context was already closed: %s", exc) + else: + # Attached via --attach-chrome to a Chrome window the operator + # started themselves; closing it here would be surprising and + # unwanted, so just detach the Playwright client. + logging.info("Leaving the attached Chrome window open.") + self._browser_context = None + self._page = None + if self._playwright is not None: + try: + await self._playwright.stop() + except Exception as exc: + logging.debug("Playwright driver was already stopped: %s", exc) + self._playwright = None + await asyncio.to_thread(self.session.close) + + async def _wait_for_request_slot(self) -> None: + """Space request starts globally, including concurrent photo downloads.""" + loop = asyncio.get_running_loop() + while True: + async with self._rate_lock: + now = loop.time() + wait = max(self._next_request_at, self._cooldown_until) - now + if wait <= 0: + self._next_request_at = now + random.uniform( + self.cfg.delay_min, self.cfg.delay_max + ) + return + await asyncio.sleep(wait) + + async def _register_429(self, retry_after: float) -> float: + """Apply one shared exponential cooldown so queued tasks also slow down.""" + async with self._rate_lock: + self._consecutive_429 += 1 + exponential = self.cfg.cooldown_base * (2 ** min(self._consecutive_429 - 1, 3)) + cooldown = max( + retry_after, + min( + self.cfg.cooldown_max, + exponential + random.uniform(0, self.cfg.cooldown_base), + ), + ) + self._cooldown_until = max( + self._cooldown_until, asyncio.get_running_loop().time() + cooldown + ) + return cooldown + + async def _register_success(self) -> None: + async with self._rate_lock: + if asyncio.get_running_loop().time() >= self._cooldown_until: + self._consecutive_429 = max(0, self._consecutive_429 - 1) + + async def get(self, url: str, *, photo: bool = False) -> requests.Response: + sem = self.photo_sem if photo else self.sem + async with sem: + for attempt in range(self.cfg.retries + 1): + try: + await self._wait_for_request_slot() + response = await asyncio.to_thread( + self.session.get, url, timeout=self.cfg.timeout + ) + if self._looks_like_cloudflare_challenge( + response.status_code, response.text, response.headers + ): + response = await self._resolve_via_browser(url) + if response.status_code in {429, 500, 502, 503, 504}: + raise requests.exceptions.HTTPError("retryable status", response=response) + response.raise_for_status() + await self._register_success() + return response + except ( + requests.exceptions.Timeout, + requests.exceptions.ConnectionError, + requests.exceptions.HTTPError, + ) as exc: + status_code = exc.response.status_code if exc.response is not None else None + retryable = status_code is None or status_code in {429, 500, 502, 503, 504} + if not retryable or attempt >= self.cfg.retries: + raise + retry_after = 0.0 + if exc.response is not None: + with contextlib.suppress(ValueError, TypeError): + retry_after = float(exc.response.headers.get("Retry-After", 0)) + if status_code == 429: + cooldown = await self._register_429(retry_after) + logging.warning( + "HTTP 429; pausing all requests for %.1f seconds (attempt %d/%d)", + cooldown, + attempt + 1, + self.cfg.retries + 1, + ) + continue + await asyncio.sleep(max(retry_after, min(60.0, (2**attempt) + random.random()))) + raise RuntimeError("unreachable") + + @staticmethod + def _looks_like_cloudflare_challenge( + status_code: int, text: str, headers: requests.structures.CaseInsensitiveDict + ) -> bool: + lowered = text.lower() + markers = ( + "challenges.cloudflare.com", + "just a moment", + "cf-chl-", + "cf-browser-verification", + "turnstile", + "attention required", + "verify you are human", + "checking your browser before accessing", + ) + return ( + status_code in {403, 503} + or headers.get("cf-mitigated", "").lower() == "challenge" + or any(marker in lowered for marker in markers) + ) + + async def _resolve_via_browser(self, url: str) -> "_BrowserResponse": + """Reuse the persistent Edge session to satisfy a Cloudflare challenge. + + The browser context keeps its own cf_clearance cookies across runs (it is a + persistent profile), and once a human has solved a challenge in it, plain + API-style requests issued through that same context (no page navigation, + no JS execution needed) inherit the clearance. This lets lightweight + HTTP-only fetches -- including photo downloads -- ride along on a browser + session that has already passed Cloudflare's check, instead of failing + outright the moment a challenge is served. + """ + if not self.cfg.cloudflare_fallback: + raise RuntimeError( + f"Cloudflare challenge detected fetching {url}. Run without " + "--http-only, or drop --no-cloudflare-fallback, to let the browser " + "session handle it." + ) + logging.warning("Cloudflare challenge on %s; retrying through the browser session", url) + async with self._browser_lock: + context = await self._ensure_browser_context() + try: + api_response = await context.request.get(url, timeout=self.cfg.timeout * 1000) + body = await api_response.body() + except Exception as exc: + raise RuntimeError( + f"Cloudflare challenge fallback failed for {url}: {exc}. If this " + "is the first request of the run, open the visible Edge window " + "and complete the challenge there, then retry." + ) from exc + headers = requests.structures.CaseInsensitiveDict(api_response.headers) + await self._sync_session_from_browser(context) + response = _BrowserResponse( + status_code=api_response.status, + headers=headers, + text=body.decode("utf-8", "replace"), + content=body, + url=api_response.url, + ) + if self._looks_like_cloudflare_challenge(response.status_code, response.text, response.headers): + raise RuntimeError( + f"Cloudflare challenge persisted for {url} even through the browser " + "session. Complete it manually in the visible Microsoft Edge window " + "(disable --playwright-headless if it's on) and rerun." + ) + return response + + async def _sync_session_from_browser(self, context) -> None: + """Mirror the browser's cookies (and real UA) onto the plain HTTP session + so subsequent lightweight requests are less likely to need the fallback.""" + try: + cookies = await context.cookies() + except Exception as exc: + logging.debug("Could not read browser cookies: %s", exc) + return + for cookie in cookies: + with contextlib.suppress(Exception): + self.session.cookies.set( + cookie["name"], + cookie["value"], + domain=cookie.get("domain", "") or urlparse(BASE_URL).netloc, + path=cookie.get("path", "/"), + ) + page = context.pages[0] if context.pages else None + if page is not None: + with contextlib.suppress(Exception): + real_ua = await page.evaluate("() => navigator.userAgent") + if real_ua: + self.session.headers["User-Agent"] = real_ua + logging.debug("Synced %d browser cookies onto the HTTP session", len(cookies)) + + async def get_html(self, url: str) -> tuple[str, bytes, str]: + if self.cfg.use_playwright: + rendered, final_url = await self.rendered_html(url) + return rendered, rendered.encode("utf-8"), final_url + response = await self.get(url) + return response.text, response.content, str(response.url) + + async def _ensure_browser_context(self): + if self._browser_context is not None: + return self._browser_context + using_patchright = True + try: + # Patchright is a maintained, drop-in fork of Playwright's async API. + # Plain Playwright issues the CDP command Runtime.enable to manage JS + # execution contexts; Cloudflare's bot management specifically checks + # for that call, which is why a challenge can clear once and then + # start looping again on a later page. Patchright avoids that command + # entirely (it runs JS in isolated contexts instead), which is what + # closes that gap. Falls back to stock Playwright if it isn't installed. + try: + from patchright.async_api import async_playwright + except ImportError: + using_patchright = False + logging.warning( + "patchright not installed; falling back to plain Playwright, " + "which is more likely to trip Cloudflare's CDP-leak detection. " + "Install with: pip install patchright" + ) + from playwright.async_api import async_playwright + except ImportError as exc: + raise RuntimeError( + "Install a Playwright-compatible driver: pip install patchright " + "(preferred, avoids Cloudflare's CDP-leak detection) or pip install " + "playwright." + ) from exc + + self._playwright = await async_playwright().start() + + if self.cfg.attach_chrome: + # Attaches to a Chrome window the operator started themselves (e.g. + # `chrome.exe --remote-debugging-port=9222 --user-data-dir=...`) + # instead of one Playwright/Patchright spawns. Cloudflare's current + # Turnstile check can flag a browser process for having been *launched* + # by an automation framework at all, independent of anything done + # inside it afterwards -- so even a genuine manual click on the + # checkbox won't validate in a Playwright-launched Chrome. Attaching + # over CDP to a normally-started Chrome sidesteps that specific signal + # (though CDP attachment itself can still be detected by other means). + try: + browser = await self._playwright.chromium.connect_over_cdp(self.cfg.cdp_url) + except Exception as exc: + port = urlparse(self.cfg.cdp_url).port or 9222 + raise RuntimeError( + f"Could not connect to Chrome at {self.cfg.cdp_url}. Start Chrome " + "yourself first with a remote debugging port open, e.g.:\n" + f' "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" ' + f'--remote-debugging-port={port} --user-data-dir="C:\\chrome-scrape-profile"\n' + "(use a separate --user-data-dir, not your everyday profile, since " + "Chrome won't let a second instance share one already in use) " + "then re-run with --attach-chrome." + ) from exc + self._owns_browser_context = False + context = browser.contexts[0] if browser.contexts else await browser.new_context() + self._browser_context = context + await self._sync_session_from_browser(context) + return context + + profile_dir = self.cfg.output_dir / "browser-profile" + profile_dir.mkdir(parents=True, exist_ok=True) + browser_options: dict[str, Any] = { + "user_data_dir": str(profile_dir), + "channel": self.cfg.browser_channel, + "headless": self.cfg.playwright_headless, + "locale": "pt-BR", + "timezone_id": "America/Sao_Paulo", + # Patchright's own guidance: a fixed viewport size is itself a + # fingerprintable tell (it never matches a real, resized browser + # window). no_viewport lets the actual window size drive it instead. + "no_viewport": True, + } + if using_patchright: + # Patchright's docs are explicit: don't add custom UA/headers or extra + # flags on top of it -- real Chrome's own defaults are more convincing + # than anything we'd hardcode, and layering our own JS patches on top + # of Patchright's isolated-context patches can reintroduce the very + # inconsistencies it's designed to avoid. + if self.cfg.browser_user_agent: + logging.warning( + "--browser-user-agent is set but patchright is active; patchright " + "recommends leaving Chrome's own user agent untouched for the " + "most convincing fingerprint." + ) + browser_options["user_agent"] = self.cfg.browser_user_agent + else: + # No Patchright available: fall back to the manual mitigations, since + # stock Playwright needs them (imperfect, but better than nothing). + browser_options["args"] = ["--disable-blink-features=AutomationControlled"] + browser_options["ignore_default_args"] = ["--enable-automation"] + if self.cfg.browser_user_agent: + browser_options["user_agent"] = self.cfg.browser_user_agent + + self._browser_context = await self._playwright.chromium.launch_persistent_context( + **browser_options, + ) + if not using_patchright: + # Belt-and-braces JS patch for the handful of properties bot-detection + # scripts check most often. Only needed without Patchright's deeper, + # driver-level patches -- redundant (and possibly counter-productive) + # alongside them. + await self._browser_context.add_init_script( + """ + Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); + window.chrome = window.chrome || { runtime: {} }; + Object.defineProperty(navigator, 'languages', { get: () => ['pt-BR', 'pt', 'en-US', 'en'] }); + Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); + const originalQuery = window.navigator.permissions.query; + window.navigator.permissions.query = (parameters) => ( + parameters.name === 'notifications' + ? Promise.resolve({ state: Notification.permission }) + : originalQuery(parameters) + ); + """ + ) + # The persistent profile may already carry a cf_clearance cookie from an + # earlier run; make it available to the plain HTTP session right away. + await self._sync_session_from_browser(self._browser_context) + return self._browser_context + + async def rendered_html(self, url: str) -> tuple[str, str]: + # Serialize browser navigation so one persistent Cloudflare session is reused safely. + async with self._browser_lock: + context = await self._ensure_browser_context() + if self._page is None or self._page.is_closed(): + # Reuse the persistent context's first (blank) tab if we launched + # it ourselves; open a fresh tab if we attached to a Chrome window + # the operator already had open, so we don't hijack one of their tabs. + if self._owns_browser_context and context.pages: + self._page = context.pages[0] + else: + self._page = await context.new_page() + page = self._page + # Page navigations previously bypassed the rate limiter entirely, so + # they fired back-to-back with no pacing -- a strong behavioral tell + # on top of anything fingerprint-related, and a likely reason a + # challenge would clear once and then reappear on the very next page. + await self._wait_for_request_slot() + await page.goto( + url, + wait_until="domcontentloaded", + timeout=int(self.cfg.timeout * 1000), + ) + try: + await page.wait_for_load_state( + "networkidle", timeout=int(self.cfg.timeout * 1000) + ) + except Exception: + logging.debug("Browser did not reach networkidle for %s", url) + with contextlib.suppress(Exception): + # A small human-like gesture; real visitors rarely leave the + # mouse at (0, 0) the instant a page finishes loading. + await page.mouse.move( + random.uniform(100, 800), random.uniform(100, 600), steps=random.randint(5, 15) + ) + + # A visible browser lets the operator solve a Cloudflare challenge manually. + challenge_markers = ( + "challenges.cloudflare.com", + "just a moment", + "cf-chl-", + "cf-browser-verification", + "turnstile", + "attention required", + "verify you are human", + "checking your browser before accessing", + ) + deadline = asyncio.get_running_loop().time() + self.cfg.challenge_timeout + while True: + try: + content = await page.content() + except Exception as exc: + if exc.__class__.__name__ in {"TargetClosedError", "Error"}: + raise RuntimeError( + "Microsoft Edge closed while the scraper was waiting for the " + "Cloudflare verification. Keep the browser window open, complete " + "the verification, and let the scraper close Edge when it finishes." + ) from exc + raise + lowered = content.lower() + if not any(marker in lowered for marker in challenge_markers): + # Solved (or no challenge was served) -- let plain HTTP requests + # (search pages in --http-only mode, photo downloads) ride on + # this session's clearance too. + await self._sync_session_from_browser(context) + return content, page.url + if self.cfg.playwright_headless: + raise RuntimeError( + "Cloudflare challenge persisted in headless mode. " + "Run without --playwright-headless and solve it in the browser window." + ) + if asyncio.get_running_loop().time() >= deadline: + raise RuntimeError( + f"Cloudflare challenge was not completed in the browser window within " + f"{self.cfg.challenge_timeout:.0f}s. Re-run, or raise --challenge-timeout " + "if you need more time to solve it manually." + ) + logging.warning("Waiting for Cloudflare challenge to be completed in the browser...") + await page.wait_for_timeout(2000) + + +class Parser: + @staticmethod + def search_links(page_html: str, page_base: str) -> list[str]: + soup = BeautifulSoup(page_html, "lxml") + out: dict[str, None] = {} + for tag in soup.select("a[href]"): + href = tag.get("href", "") + absolute = canonical_url(urljoin(page_base, href)) + if listing_id_from_url(absolute) and "/imovel/" in urlparse(absolute).path: + out[absolute] = None + for match in ABS_LISTING_RE.finditer(page_html): + out[canonical_url(match.group(0))] = None + return list(out) + + @staticmethod + def json_ld(soup: BeautifulSoup) -> list[dict[str, Any]]: + objects: list[dict[str, Any]] = [] + for node in soup.select('script[type="application/ld+json"]'): + raw = node.string or node.get_text() + try: + parsed = json.loads(raw) + except (json.JSONDecodeError, TypeError): + continue + candidates = parsed if isinstance(parsed, list) else [parsed] + for item in candidates: + if isinstance(item, dict) and isinstance(item.get("@graph"), list): + candidates.extend(x for x in item["@graph"] if isinstance(x, dict)) + if isinstance(item, dict): + objects.append(item) + return objects + + @staticmethod + def _meta(soup: BeautifulSoup, *names: str) -> str | None: + for name in names: + node = soup.find("meta", attrs={"property": name}) or soup.find("meta", attrs={"name": name}) + if node and node.get("content"): + return clean_text(node["content"]) + return None + + @classmethod + def detail(cls, url: str, page_html: str, raw_path: str | None = None) -> tuple[dict[str, Any], list[str]]: + soup = BeautifulSoup(page_html, "lxml") + full_text = clean_text(soup.get_text(" ", strip=True)) or "" + listing_id = listing_id_from_url(url) + if not listing_id: + raise ValueError(f"Cannot determine listing id from {url}") + + ld = cls.json_ld(soup) + primary = next((x for x in ld if str(x.get("@type", "")).lower() in { + "product", "realestatelisting", "apartment", "house", "residence", "singlefamilyresidence" + }), ld[0] if ld else {}) + + title = clean_text(primary.get("name")) or cls._meta(soup, "og:title", "twitter:title") + if not title and soup.title: + title = clean_text(soup.title.get_text()) + description = clean_text(primary.get("description")) or cls._meta(soup, "og:description", "description") + + offers = primary.get("offers") if isinstance(primary.get("offers"), dict) else {} + price = parse_brl(str(offers.get("price", ""))) + if price is None: + price = parse_brl(cls._meta(soup, "product:price:amount")) + if price is None: + # Prefer visible price blocks, then first BRL occurrence. + price_nodes = soup.select('[class*="preco" i], [class*="price" i], [itemprop="price"]') + for node in price_nodes: + price = parse_brl(node.get("content") or node.get_text(" ", strip=True)) + if price is not None: + break + + address_obj = primary.get("address") if isinstance(primary.get("address"), dict) else {} + geo = primary.get("geo") if isinstance(primary.get("geo"), dict) else {} + address = clean_text(address_obj.get("streetAddress")) + city = clean_text(address_obj.get("addressLocality")) + state = clean_text(address_obj.get("addressRegion")) + + area = None + for pattern in [r"(?:área(?:\s+privativa|\s+útil)?|area)\s*[:\-]?\s*([\d.,]+)\s*m[²2]", r"([\d.,]+)\s*m[²2]"]: + m = re.search(pattern, full_text, re.I) + if m: + area = parse_number(m.group(1)) + break + + def label_money(*labels: str) -> float | None: + for label in labels: + m = re.search(rf"{label}\s*[:\-]?\s*(R\$\s*[\d.]+(?:,\d{{1,2}})?)", full_text, re.I) + if m: + return parse_brl(m.group(1)) + return None + + def label_text(*labels: str) -> str | None: + for label in labels: + m = re.search(rf"{label}\s*[:\-]?\s*([^|•]+?)(?=\s{{2,}}|\b(?:Código|Creci|Condomínio|IPTU)\b|$)", full_text, re.I) + if m: + return clean_text(m.group(1)) + return None + + path_parts = [x for x in urlparse(url).path.split("/") if x] + slug = path_parts[-1] if path_parts else "" + transaction_type = "venda" if "venda" in slug else "aluguel" if "aluguel" in slug else None + property_type = next((x for x in ["apartamento", "casa", "cobertura", "terreno", "loja", "sala", "kitnet", "galpao"] if x in slug), None) + + image_urls: dict[str, None] = {} + candidates: list[Any] = [primary.get("image"), cls._meta(soup, "og:image", "twitter:image")] + for obj in ld: + candidates.append(obj.get("image")) + for candidate in candidates: + vals = candidate if isinstance(candidate, list) else [candidate] + for val in vals: + if isinstance(val, dict): + val = val.get("url") or val.get("contentUrl") + if isinstance(val, str) and val.startswith(("http://", "https://")): + image_urls[html.unescape(val)] = None + for tag in soup.select("img, source"): + for attr in ("src", "data-src", "data-lazy", "data-original", "data-zoom-image", "srcset", "data-srcset"): + raw = tag.get(attr) + if not raw: + continue + for part in str(raw).split(","): + candidate = part.strip().split(" ")[0] + if candidate and not candidate.startswith("data:"): + absolute = urljoin(url, candidate) + if re.search(r"\.(?:jpe?g|png|webp|avif)(?:\?|$)", absolute, re.I): + image_urls[absolute] = None + for match in IMAGE_URL_RE.finditer(page_html.replace("\\/", "/")): + image_urls[html.unescape(match.group(0))] = None + + # Exclude common site chrome and tiny assets based on URL hints. + photos = [u for u in image_urls if not re.search(r"logo|favicon|sprite|icon|avatar|banner", u, re.I)] + + data = { + "listing_id": listing_id, + "url": canonical_url(url), + "title": title, + "description": description, + "transaction_type": transaction_type, + "property_type": property_type, + "price_brl": price, + "condominium_brl": label_money("Condomínio", "Condominio"), + "iptu_brl": label_money("IPTU"), + "area_m2": area, + "bedrooms": int_near_label(full_text, ["quartos?", "dormitórios?"]), + "suites": int_near_label(full_text, ["suítes?", "suites?"]), + "parking_spaces": int_near_label(full_text, ["vagas?"]), + "address": address or label_text("Endereço", "Endereco"), + "neighborhood": clean_text(address_obj.get("addressSubregion")) or label_text("Bairro"), + "city": city, + "state": state, + "advertiser_name": clean_text(primary.get("seller", {}).get("name")) if isinstance(primary.get("seller"), dict) else None, + "advertiser_code": label_text("Código", "Codigo"), + "creci": label_text("CRECI"), + "latitude": parse_number(str(geo.get("latitude"))) if geo.get("latitude") is not None else None, + "longitude": parse_number(str(geo.get("longitude"))) if geo.get("longitude") is not None else None, + "published_at": clean_text(primary.get("datePosted") or primary.get("datePublished")), + "raw_html_path": raw_path, + "extra_json": json.dumps({"json_ld": ld}, ensure_ascii=False), + } + stable = {k: v for k, v in data.items() if k not in {"content_hash", "raw_html_path"}} + data["content_hash"] = hashlib.sha256(json.dumps(stable, sort_keys=True, ensure_ascii=False).encode()).hexdigest() + return data, photos + + +class Scraper: + def __init__(self, cfg: Config) -> None: + self.cfg = cfg + self.db = Database(cfg.database) + self.fetcher = Fetcher(cfg) + self.stats = {"search_pages": 0, "listing_urls": 0, "details_ok": 0, "details_failed": 0, "changed": 0, "photos_ok": 0, "photos_skipped": 0, "photos_failed": 0, "inactive": 0} + self.run_started_at = now_utc() + + def save_raw(self, kind: str, name: str, content: bytes) -> str: + stamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ") + path = self.cfg.output_dir / "raw" / kind / safe_name(name) / f"{stamp}.html.gz" + path.parent.mkdir(parents=True, exist_ok=True) + with gzip.open(path, "wb", compresslevel=6) as f: + f.write(content) + return str(path.relative_to(self.cfg.output_dir)) + + async def discover(self, search_url: str) -> list[str]: + found: dict[str, None] = {} + page = 1 + consecutive_empty = 0 + while True: + if self.cfg.max_pages and page > self.cfg.max_pages: + break + url = page_url(search_url, page) + logging.info("Search page %d: %s", page, url) + page_html, body, final_url = await self.fetcher.get_html(url) + links = Parser.search_links(page_html, final_url) + self.stats["search_pages"] += 1 + if self.cfg.save_raw_html: + self.save_raw("search", f"{hashlib.sha1(search_url.encode()).hexdigest()[:12]}_p{page}", body) + new = [x for x in links if x not in found] + logging.info("Found %d links (%d new)", len(links), len(new)) + for x in new: + found[x] = None + consecutive_empty = consecutive_empty + 1 if not new else 0 + if not links or consecutive_empty >= 2: + break + page += 1 + self.stats["listing_urls"] += len(found) + return list(found) + + async def scrape_detail(self, url: str, search_url: str) -> None: + try: + page_html, body, final_url = await self.fetcher.get_html(url) + raw_path = self.save_raw("listing", listing_id_from_url(url) or "unknown", body) if self.cfg.save_raw_html else None + data, photos = Parser.detail(final_url, page_html, raw_path) + changed = self.db.upsert_listing(data, search_url) + self.stats["details_ok"] += 1 + self.stats["changed"] += int(changed) + logging.info("Listing %s: %s; %d photos", data["listing_id"], "changed" if changed else "unchanged", len(photos)) + for ordinal, photo_url in enumerate(photos, start=1): + self.db.upsert_photo(data["listing_id"], ordinal, photo_url) + if self.cfg.download_photos: + await asyncio.gather(*(self.download_photo(data["listing_id"], i, u) for i, u in enumerate(photos, 1))) + except Exception: + self.stats["details_failed"] += 1 + logging.exception("Failed listing: %s", url) + + async def download_photo(self, listing_id: str, ordinal: int, url: str) -> None: + try: + existing = self.db.get_photo(listing_id, url) + if existing and existing["sha256"] and existing["local_path"]: + local_path = self.cfg.output_dir / existing["local_path"] + if local_path.is_file(): + self.stats["photos_skipped"] += 1 + logging.debug("Reusing photo for %s: %s", listing_id, local_path) + return + + response = await self.fetcher.get(url, photo=True) + content_type = response.headers.get("content-type", "").split(";", 1)[0].lower() + if not content_type.startswith("image/"): + raise ValueError(f"Not an image: {content_type}") + content = response.content + digest = sha256_bytes(content) + suffix = mimetypes.guess_extension(content_type) or Path(urlparse(url).path).suffix or ".img" + if suffix == ".jpe": + suffix = ".jpg" + path = self.cfg.output_dir / "photos" / listing_id / f"{ordinal:03d}_{digest[:16]}{suffix}" + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists(): + path.write_bytes(content) + self.db.upsert_photo(listing_id, ordinal, url, sha256=digest, mime_type=content_type, bytes=len(content), local_path=str(path.relative_to(self.cfg.output_dir))) + self.stats["photos_ok"] += 1 + except Exception: + self.stats["photos_failed"] += 1 + logging.exception("Failed photo for %s: %s", listing_id, url) + + async def run(self) -> dict[str, Any]: + run_id = self.db.start_run() + status = "ok" + try: + for search_url in self.cfg.searches: + urls = await self.discover(search_url) + seen_ids = {x for u in urls if (x := listing_id_from_url(u))} + # Detail concurrency remains bounded inside Fetcher. + await asyncio.gather(*(self.scrape_detail(url, search_url) for url in urls)) + self.stats["inactive"] += self.db.mark_inactive(search_url, seen_ids, self.run_started_at) + except Exception: + status = "failed" + raise + finally: + self.db.finish_run(run_id, status, self.stats) + await self.fetcher.close() + self.db.close() + return self.stats + + +def load_searches(args: argparse.Namespace) -> list[str]: + searches = list(args.search or []) + if args.search_file: + for line in Path(args.search_file).read_text(encoding="utf-8").splitlines(): + line = line.strip() + if line and not line.startswith("#"): + searches.append(line) + normalized = [] + for url in searches: + if not urlparse(url).netloc: + url = urljoin(BASE_URL, url) + normalized.append(canonical_url(url)) + return list(dict.fromkeys(normalized)) + + +def build_arg_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="Authorized DFImoveis scraper") + p.add_argument("--search", action="append", help="Search URL; repeat for multiple searches") + p.add_argument("--search-file", help="Text file containing one search URL per line") + p.add_argument("--output", default="dfimoveis_data", help="Output directory") + p.add_argument("--database", help="SQLite path; defaults to OUTPUT/dfimoveis.sqlite3") + p.add_argument("--max-pages", type=int, default=0, help="0 means continue until exhausted") + p.add_argument("--concurrency", type=int, default=2) + p.add_argument("--photo-concurrency", type=int, default=2) + p.add_argument("--delay-min", type=float, default=3.0) + p.add_argument("--delay-max", type=float, default=6.0) + p.add_argument("--cooldown-base", type=float, default=30.0) + p.add_argument("--cooldown-max", type=float, default=300.0) + p.add_argument("--timeout", type=float, default=30.0) + p.add_argument("--retries", type=int, default=4) + p.add_argument("--no-photos", action="store_true") + p.add_argument("--no-raw-html", action="store_true") + p.add_argument( + "--http-only", + action="store_true", + help="Use direct HTTP requests instead of the default local Microsoft Edge browser", + ) + p.add_argument( + "--playwright-headless", + action="store_true", + help="Run Playwright without a visible browser (not recommended for Cloudflare challenges)", + ) + p.add_argument( + "--no-cloudflare-fallback", + action="store_true", + help=( + "Disable the automatic fallback that retries a challenged HTTP-only or " + "photo request through the persistent browser session" + ), + ) + p.add_argument( + "--challenge-timeout", + type=float, + default=180.0, + help="Seconds to wait for a Cloudflare challenge to be solved in the visible browser window", + ) + p.add_argument( + "--attach-chrome", + action="store_true", + help=( + "Attach to a Chrome window you started yourself (via --remote-debugging-port) " + "instead of one this script launches. Needed when Cloudflare's Turnstile flags " + "automation-launched Chrome even after a genuine manual click; see --cdp-url." + ), + ) + p.add_argument( + "--cdp-url", + default=os.getenv("DFIMOVEIS_CDP_URL", "http://localhost:9222"), + help="CDP endpoint of the already-running Chrome (used with --attach-chrome)", + ) + p.add_argument("--user-agent", default=os.getenv("DFIMOVEIS_USER_AGENT", DEFAULT_USER_AGENT)) + p.add_argument( + "--browser-channel", + default=os.getenv("DFIMOVEIS_BROWSER_CHANNEL", "chrome"), + choices=["chrome", "msedge", "chromium", "chrome-beta", "msedge-beta", "msedge-dev"], + help=( + "Which installed browser Playwright/Patchright should drive. 'chrome' " + "(the default) is what Patchright's own guidance recommends -- the " + "bundled 'chromium' binary is a stealth downgrade, not an upgrade." + ), + ) + p.add_argument( + "--browser-user-agent", + default=os.getenv("DFIMOVEIS_BROWSER_USER_AGENT"), + help=( + "Override the browser's native user agent. Not recommended when " + "patchright is installed -- its own default is more convincing than " + "any override." + ), + ) + p.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR"]) + return p + + +async def async_main() -> int: + args = build_arg_parser().parse_args() + logging.basicConfig(level=getattr(logging, args.log_level), format="%(asctime)s %(levelname)s %(message)s") + searches = load_searches(args) + if not searches: + print("Provide at least one --search URL or --search-file", file=sys.stderr) + return 2 + output = Path(args.output).expanduser().resolve() + output.mkdir(parents=True, exist_ok=True) + cfg = Config( + searches=searches, + output_dir=output, + database=Path(args.database).expanduser().resolve() if args.database else output / "dfimoveis.sqlite3", + max_pages=max(0, args.max_pages), + concurrency=max(1, args.concurrency), + photo_concurrency=max(1, args.photo_concurrency), + delay_min=max(0, args.delay_min), + delay_max=max(0, args.delay_min, args.delay_max), + cooldown_base=max(1, args.cooldown_base), + cooldown_max=max(1, args.cooldown_base, args.cooldown_max), + timeout=max(1, args.timeout), + retries=max(0, args.retries), + download_photos=not args.no_photos, + save_raw_html=not args.no_raw_html, + use_playwright=not args.http_only, + playwright_headless=args.playwright_headless, + user_agent=args.user_agent, + browser_user_agent=args.browser_user_agent, + browser_channel=args.browser_channel, + attach_chrome=args.attach_chrome, + cdp_url=args.cdp_url, + cloudflare_fallback=not args.no_cloudflare_fallback, + challenge_timeout=max(10.0, args.challenge_timeout), + ) + started = time.monotonic() + scraper = Scraper(cfg) + stats = await scraper.run() + stats["elapsed_seconds"] = round(time.monotonic() - started, 2) + print(json.dumps(stats, ensure_ascii=False, indent=2)) + return 0 + + +def main() -> None: + try: + raise SystemExit(asyncio.run(async_main())) + except KeyboardInterrupt: + raise SystemExit(130) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/docs/ACTUAL_SCHEMA.md b/docs/ACTUAL_SCHEMA.md new file mode 100644 index 0000000..b80e3fe --- /dev/null +++ b/docs/ACTUAL_SCHEMA.md @@ -0,0 +1,238 @@ +# Esquema físico do banco `dfimoveis.sqlite3` + +## 1. Escopo e identificação do snapshot + +Este documento descreve o esquema observado em 13 de julho de 2026. Ele não altera o modelo conceitual de `docs/DATA_MODEL.md` e não atribui significado não comprovado às colunas. + +| Item | Valor | +|---|---| +| Caminho | `dfimoveis_data/dfimoveis.sqlite3` | +| Tamanho | 5.017.600 bytes | +| SHA-256 | `1ce3128b488c50c993d7328ac6e24b5905f083a7e04e3f636407eea1edd02b9d` | +| Modificação do arquivo | `2026-07-13 14:20:34.601285975 -03:00` | +| SQLite | 3.45.1 | +| Codificação | UTF-8 | +| `journal_mode` persistido | WAL | +| Páginas | 1.225 páginas de 4.096 bytes; `freelist_count = 0` | +| `user_version` / `application_id` | 0 / 0 | +| Método de abertura | `sqlite3 -readonly` e URI `mode=ro&immutable=1`, sempre com `PRAGMA query_only = ON` | + +Não havia arquivo `-wal` ou `-shm` nem processo com o banco aberto na verificação inicial. Isso permitiu tratar o conteúdo como snapshot imutável. `PRAGMA quick_check` retornou `ok` e `PRAGMA foreign_key_check` não retornou violações. Na validação final, o cliente SQLite havia criado os sidecars de runtime `dfimoveis.sqlite3-shm` (32.768 bytes) e `dfimoveis.sqlite3-wal` (vazio), comportamento possível ao abrir em leitura um banco configurado em WAL. Eles não foram removidos. O arquivo principal manteve tamanho, `mtime` e SHA-256 idênticos. + +## 2. Visão geral + +O banco possui cinco tabelas da aplicação e a tabela interna `sqlite_sequence`. Não há views nem triggers. Os quatro índices existentes são autoíndices criados por chaves primárias ou restrições `UNIQUE`. + +```mermaid +erDiagram + listings ||--|{ listing_history : "listing_id" + listings ||--|{ listing_searches : "listing_id" + listings ||--|{ photos : "listing_id" + crawl_runs { + INTEGER id PK + TEXT started_at + TEXT finished_at + TEXT status + TEXT stats_json + } + listings { + TEXT listing_id PK + TEXT url + REAL price_brl + REAL area_m2 + TEXT first_seen_at + TEXT last_seen_at + TEXT content_hash + } + listing_history { + INTEGER id PK + TEXT listing_id FK + TEXT captured_at + TEXT content_hash + TEXT data_json + } + listing_searches { + TEXT listing_id PK,FK + TEXT search_url PK + TEXT first_seen_at + TEXT last_seen_at + } + photos { + TEXT listing_id PK,FK + TEXT source_url PK + INTEGER ordinal + TEXT sha256 + TEXT local_path + } +``` + +`crawl_runs` não possui relacionamento físico com anúncios ou snapshots. Portanto, a execução que gerou uma linha só pode ser inferida pelos horários, não recuperada por uma chave. + +### Contagens e cardinalidades observadas + +| Tabela | Linhas | Interpretação física | +|---|---:|---| +| `crawl_runs` | 4 | Execuções do coletor | +| `listings` | 156 | Identidades de anúncios e seu estado corrente | +| `listing_history` | 156 | Estados distintos dos anúncios por hash | +| `listing_searches` | 156 | Vínculos entre anúncio e URL de busca | +| `photos` | 5.386 | Mídias associadas diretamente aos anúncios | +| `sqlite_sequence` | 2 | Sequências internas de tabelas `AUTOINCREMENT` | + +No snapshot avaliado: + +- cada anúncio possui exatamente um registro em `listing_history`; +- cada anúncio está ligado a exatamente uma URL de busca; +- cada anúncio possui de 5 a 61 linhas em `photos`; +- não há órfãos nas três relações com `listings`; +- os 156 hashes de `listing_history` coincidem com o hash corrente em `listings`; +- `captured_at` coincide com `first_seen_at` nos 156 anúncios. + +## 3. Dicionário de tabelas + +### 3.1 `crawl_runs` + +Representa execuções técnicas do coletor. Não registra fonte, versão do scraper, configuração, texto de erro ou relacionamento direto com os itens coletados. + +| Coluna | Tipo | Nulo? | Chave/default | Interpretação | +|---|---|---:|---|---| +| `id` | INTEGER | Não | PK, `AUTOINCREMENT` | Identificador da execução; a PK inteira atribui um valor quando omitido | +| `started_at` | TEXT | Não | — | Início em ISO 8601, observado em UTC | +| `finished_at` | TEXT | Sim | — | Término em ISO 8601 | +| `status` | TEXT | Não | — | Estado textual da execução; somente `ok` na amostra | +| `stats_json` | TEXT | Não | `'{}'` | Contadores técnicos em JSON | + +As quatro estruturas JSON são válidas. As chaves presentes são `search_pages`, `listing_urls`, `details_ok`, `details_failed`, `changed`, `photos_ok`, `photos_failed` e `inactive`, todas inteiras. + +### 3.2 `listings` + +Representa a identidade do anúncio no portal e uma cópia mutável de seu estado mais recente. Não representa um imóvel físico deduplicado. + +| Coluna | Tipo | Nulo? | Chave/default | Interpretação e ressalvas | +|---|---|---:|---|---| +| `listing_id` | TEXT | Sim no DDL; não observado | PK | Identificador do anúncio no portal; todos os valores atuais têm 6–7 dígitos. Por peculiaridade do SQLite, PK textual sem `NOT NULL` explícito merece validação na aplicação | +| `url` | TEXT | Não | — | URL do anúncio | +| `title` | TEXT | Sim | — | Título extraído | +| `description` | TEXT | Sim | — | Descrição curta extraída; não é o HTML bruto completo | +| `transaction_type` | TEXT | Sim | — | Tipo da transação; somente `venda` no snapshot | +| `property_type` | TEXT | Sim | — | Tipo anunciado; `apartamento` ou `casa` | +| `price_brl` | REAL | Sim | — | Preço anunciado em reais; sujeito a erros de escala | +| `condominium_brl` | REAL | Sim | — | Condomínio anunciado; periodicidade não está formalizada | +| `iptu_brl` | REAL | Sim | — | IPTU anunciado; periodicidade não está formalizada | +| `area_m2` | REAL | Sim | — | Uma única área em m², sem indicar se é útil, privativa, construída, total ou terreno | +| `bedrooms` | INTEGER | Sim | — | Quartos anunciados, não a planta original comprovada | +| `suites` | INTEGER | Sim | — | Suítes anunciadas | +| `parking_spaces` | INTEGER | Sim | — | Vagas anunciadas | +| `address` | TEXT | Sim | — | Texto de endereço; fortemente contaminado pela página na coleta atual | +| `neighborhood` | TEXT | Sim | — | Bairro; fortemente contaminado pela página na coleta atual | +| `city` | TEXT | Sim | — | Cidade; 100% nula atualmente | +| `state` | TEXT | Sim | — | UF; 100% nula atualmente | +| `advertiser_name` | TEXT | Sim | — | Nome do anunciante; 100% nulo atualmente | +| `advertiser_code` | TEXT | Sim | — | Código do anunciante; parte relevante dos valores está contaminada | +| `creci` | TEXT | Sim | — | Registro do anunciante; parte relevante dos valores está contaminada | +| `latitude` | REAL | Sim | — | Latitude; 100% nula atualmente | +| `longitude` | REAL | Sim | — | Longitude; 100% nula atualmente | +| `published_at` | TEXT | Sim | — | Data publicada pelo portal; 100% nula atualmente | +| `first_seen_at` | TEXT | Não | — | Primeira observação do anúncio | +| `last_seen_at` | TEXT | Não | — | Última observação do anúncio | +| `inactive_at` | TEXT | Sim | — | Momento de inativação inferida pelo coletor; 100% nulo atualmente | +| `content_hash` | TEXT | Sim | — | SHA-256 do conteúdo normalizado usado para detectar mudança | +| `raw_html_path` | TEXT | Sim | — | Caminho relativo para o HTML bruto do anúncio | +| `extra_json` | TEXT | Não | `'{}'` | JSON adicional; contém apenas `json_ld`, um array vazio nos 156 anúncios | + +Exemplo não sensível de identidade: o anúncio `1026522` aponta para uma URL sob `www.dfimoveis.com.br` e para um HTML em `raw/listing/1026522/...html.gz`. + +### 3.3 `listing_history` + +Representa estados distintos de um anúncio, não cada tentativa ou observação do coletor. + +| Coluna | Tipo | Nulo? | Chave/default | Interpretação | +|---|---|---:|---|---| +| `id` | INTEGER | Não | PK, `AUTOINCREMENT` | Identificador do estado histórico; a PK inteira atribui um valor quando omitido | +| `listing_id` | TEXT | Não | FK | Anúncio ao qual o estado pertence | +| `captured_at` | TEXT | Não | — | Momento em que o estado foi capturado | +| `content_hash` | TEXT | Não | `UNIQUE` com `listing_id` | Hash que impede repetir um estado idêntico para o anúncio | +| `data_json` | TEXT | Não | — | Snapshot JSON dos principais campos extraídos | + +`data_json` é JSON válido em todas as 156 linhas. Ele contém os campos correntes de conteúdo, mas não contém `first_seen_at`, `last_seen_at`, `inactive_at`, `raw_html_path` ou uma referência à execução. Como a restrição é `UNIQUE(listing_id, content_hash)`, observações repetidas sem mudança não geram novos snapshots. + +### 3.4 `listing_searches` + +Tabela de associação entre anúncio e consulta de busca que o encontrou. + +| Coluna | Tipo | Nulo? | Chave/default | Interpretação | +|---|---|---:|---|---| +| `listing_id` | TEXT | Não | PK parcial, FK | Anúncio | +| `search_url` | TEXT | Não | PK parcial | URL/configuração da busca | +| `first_seen_at` | TEXT | Não | — | Primeira observação nessa busca | +| `last_seen_at` | TEXT | Não | — | Última observação nessa busca | + +A chave primária composta é `(listing_id, search_url)`. Há duas URLs distintas no banco, uma para apartamentos de três quartos no Cruzeiro Novo e outra para casas anunciadas com três ou quatro quartos no Jardins Mangueiral. + +### 3.5 `photos` + +Representa arquivos de mídia associados diretamente ao anúncio. A tabela não preserva associação por snapshot. + +| Coluna | Tipo | Nulo? | Chave/default | Interpretação | +|---|---|---:|---|---| +| `listing_id` | TEXT | Não | PK parcial, FK | Anúncio | +| `ordinal` | INTEGER | Não | — | Ordem da imagem no anúncio | +| `source_url` | TEXT | Não | PK parcial | URL de origem da imagem | +| `sha256` | TEXT | Sim | — | Hash do arquivo baixado | +| `mime_type` | TEXT | Sim | — | Tipo MIME detectado | +| `bytes` | INTEGER | Sim | — | Tamanho do arquivo | +| `local_path` | TEXT | Sim | — | Caminho relativo sob `dfimoveis_data/` | +| `first_seen_at` | TEXT | Não | — | Primeira observação da mídia | +| `last_seen_at` | TEXT | Não | — | Última observação da mídia | + +A chave primária é `(listing_id, source_url)`, e não `(listing_id, ordinal)`. Não há ordinais repetidos dentro de um anúncio na amostra atual. Exemplo de caminho: `photos/1026522/001_9d7f11205f0a4a3f.jpg`. + +### 3.6 `sqlite_sequence` + +Tabela interna do SQLite que mantém as sequências `AUTOINCREMENT` de `crawl_runs` e `listing_history`. Não é entidade do domínio. + +## 4. Chaves, índices e integridade referencial + +| Índice | Tabela | Origem | Colunas lógicas | +|---|---|---|---| +| `sqlite_autoindex_listings_1` | `listings` | PK | `listing_id` | +| `sqlite_autoindex_listing_history_1` | `listing_history` | `UNIQUE` | `listing_id`, `content_hash` | +| `sqlite_autoindex_listing_searches_1` | `listing_searches` | PK | `listing_id`, `search_url` | +| `sqlite_autoindex_photos_1` | `photos` | PK | `listing_id`, `source_url` | + +As FKs de `listing_history`, `listing_searches` e `photos` apontam para `listings(listing_id)`, com `NO ACTION` para atualização e exclusão. Não há índices explícitos por data, preço, região ou hash de foto. Com o volume atual isso não é um problema operacional; se a base crescer, índices analíticos devem ser criados apenas em uma camada derivada ou recomendados para uma migração aprovada, nunca adicionados ao banco original durante análise. + +## 5. Correspondência com o modelo conceitual + +| Conceito desejado | Implementação física | Avaliação | +|---|---|---| +| Fonte | Ausente; DF Imóveis é implícito nas URLs | Não permite múltiplas fontes com identidade segura | +| Execução de coleta | `crawl_runs` | Parcial; faltam fonte, configuração, versão e relação com itens | +| Anúncio | `listings` | Presente; estado corrente e identidade estão na mesma linha | +| Observação histórica | `listing_history` | Parcial; guarda somente conteúdo alterado, sem `run_id` | +| Imóvel físico candidato | Ausente | Nenhuma deduplicação física é persistida | +| Localização normalizada | Colunas em `listings` | Insuficiente e atualmente contaminada | +| Mídia | `photos` | Parcial; vinculada ao anúncio, não ao snapshot | +| Origem por consulta | `listing_searches` | Presente como relação anúncio–URL de busca | + +## 6. Decisões de interpretação e ambiguidades + +1. `listing_id` identifica um **anúncio no portal**, não um imóvel físico. +2. Uma linha de `listing_history` é um **estado de conteúdo distinto**, não prova de que o anúncio foi observado somente uma vez. Observações idênticas são condensadas em `last_seen_at`. +3. O banco ainda não contém histórico suficiente para preços ou permanência: há um snapshot por anúncio e todos os anúncios têm `first_seen_at = last_seen_at`. +4. Anúncios diferentes com preço, área ou fotos semelhantes permanecem anúncios distintos. Não há chave de endereço/unidade nem tabela de candidatos físicos. +5. `area_m2` não deve ser usada para preço por m² até que o conceito de área seja validado por tipologia e fonte. +6. `bedrooms` é a quantidade anunciada. Ela não comprova a planta original de três quartos exigida para Jardins Mangueiral. +7. Datas com sufixo `+00:00` são tratadas como UTC. A cobertura observada é de um único dia. +8. Os campos textuais contaminados devem ser reextraídos do HTML bruto em uma camada derivada; os valores atuais não devem ser silenciosamente normalizados como se fossem corretos. + +## 7. Problemas conhecidos do snapshot + +- 146 de 156 endereços têm mais de 300 caracteres; 154 de 156 bairros têm mais de 100 caracteres. Os dois bairros curtos também não formam valores confiáveis. +- `city`, `state`, `latitude`, `longitude`, `published_at` e `advertiser_name` estão integralmente ausentes. +- 98 códigos de anunciante e 153 valores de CRECI são longos demais para o significado esperado, indicando extração contaminada. +- Há um preço de R$ 570.000.000 e uma área de 0,11 m², ambos candidatos fortes a erro de extração/escala. +- O JSON-LD reservado em `extra_json` é um array vazio em todos os anúncios e não oferece uma fonte estruturada alternativa no snapshot. +- Três downloads de foto falharam e deixaram linhas sem hash, MIME, tamanho e caminho local. +- Hashes de foto idênticos aparecem em muitos anúncios, inclusive um hash presente nos 156; isso demonstra a presença de ativos genéricos e impede usar igualdade de hash como prova isolada de imóvel duplicado. +- Não há como ligar formalmente anúncio/snapshot a `crawl_runs`, nem distinguir por chave a fonte se uma segunda fonte for incorporada. diff --git a/docs/ANALYSIS_RULES.md b/docs/ANALYSIS_RULES.md new file mode 100644 index 0000000..7bd3e70 --- /dev/null +++ b/docs/ANALYSIS_RULES.md @@ -0,0 +1,542 @@ +# Regras de análise + +## 1. Objetivo metodológico + +Produzir análises reproduzíveis e úteis para decisão de compra, sem transformar dados de anúncios em uma falsa precisão de mercado. + +Toda conclusão deve distinguir: + +- o que os dados mostram; +- o que foi inferido; +- o que permanece desconhecido; +- o que depende de visita, documentação ou negociação. + +--- + +## 2. Unidade de análise + +Definir antes de calcular: + +- anúncio único; +- snapshot de anúncio; +- imóvel físico provável; +- imóvel físico confirmado; +- região; +- tipologia; +- período. + +Não misturar unidades. + +Exemplo de erro: + +> Contar cada snapshot mensal como um imóvel diferente. + +Exemplo correto: + +> Para estoque histórico, usar um anúncio ativo por data; para oferta única, deduplicar anúncios e imóveis prováveis. + +--- + +## 3. Cobertura e consistência temporal + +Antes de comparar meses: + +- verificar dias coletados; +- verificar fontes ativas; +- verificar falhas; +- verificar mudanças do scraper; +- verificar novos filtros; +- verificar alterações no site; +- verificar interrupções; +- verificar mudanças de campos. + +Uma queda no número de anúncios pode ser falha de coleta. + +Relatórios temporais devem exibir cobertura. + +--- + +## 4. Limpeza + +### 4.1 Preços inválidos + +Identificar: + +- zero; +- preço mensal; +- preço de aluguel em anúncio de venda; +- valor parcial; +- erro de escala; +- preço simbólico; +- preço por fração; +- moeda incorreta; +- preço inconsistente com descrição. + +Não remover silenciosamente. Registrar regra e contagem. + +### 4.2 Áreas inválidas + +Identificar: + +- zero; +- área total usada como privativa; +- área do terreno; +- área multiplicada; +- vírgula decimal interpretada incorretamente; +- área do condomínio; +- área incompatível com a tipologia. + +### 4.3 Quartos e tipologia + +Verificar divergências entre: + +- título; +- campos estruturados; +- descrição; +- planta; +- fotografias; +- tipologia original. + +Para Jardins Mangueiral, não aceitar tipologia anunciada como prova da planta original. + +--- + +## 5. Preço por metro quadrado + +Calcular somente quando: + +- o preço é válido; +- a área é válida; +- a área representa conceito comparável; +- o imóvel pertence a uma tipologia comparável. + +Registrar a área usada. + +Não comparar diretamente: + +- área privativa com área total; +- casa com apartamento; +- imóvel reformado com imóvel para reforma sem controle; +- dois quartos com três quartos apenas pela média regional; +- cobertura com unidade padrão; +- casa ampliada com planta original sem ajustar a área. + +Estatísticas mínimas: + +- número de observações; +- mediana; +- percentil 25; +- percentil 75; +- mínimo e máximo após validação; +- proporção excluída. + +--- + +## 6. Outliers + +Não excluir outliers apenas porque são extremos. + +Investigar se representam: + +- erro; +- imóvel premium; +- reforma; +- venda urgente; +- leilão; +- área incorreta; +- anúncio duplicado; +- preço desatualizado; +- unidade atípica. + +Apresentar resultados com e sem outliers quando a conclusão mudar. + +Métodos permitidos: + +- IQR; +- MAD; +- limites específicos por tipologia; +- inspeção manual; +- modelos robustos. + +Não usar corte automático sem explicar. + +--- + +## 7. Duplicidades + +Relatar: + +- duplicatas dentro da mesma fonte; +- duplicatas entre fontes; +- snapshots repetidos; +- imóveis anunciados por vários corretores; +- anúncios republicados com novo identificador. + +Para preço de oferta atual, escolher uma regra explícita, por exemplo: + +- menor preço vigente por imóvel provável; +- anúncio mais recente; +- anúncio primário; +- mediana entre anúncios do mesmo imóvel. + +Testar sensibilidade da conclusão à regra. + +--- + +## 8. Histórico de anúncios + +Para cada anúncio ou imóvel provável, tentar identificar: + +- primeira aparição; +- última aparição; +- tempo observado; +- mudanças de preço; +- períodos de ausência; +- republicação; +- alteração de corretor; +- alteração de descrição; +- alteração de área ou quartos. + +Não concluir “vendido” apenas porque desapareceu. + +Estados possíveis: + +- ativo; +- ausente na coleta; +- removido; +- republicado; +- provável venda; +- status desconhecido. + +“Provável venda” exige sinais adicionais. + +--- + +## 9. Comparáveis + +Um conjunto de comparáveis deve controlar, quando possível: + +- região; +- quadra, bloco ou QC; +- tipo de imóvel; +- planta original; +- quartos; +- área; +- garagem; +- elevador; +- andar; +- posição; +- reforma; +- conservação; +- data; +- condomínio; +- situação de ocupação. + +Quando não for possível controlar, declarar a limitação. + +Nunca usar uma média ampla do bairro como avaliação definitiva de uma unidade específica. + +--- + +## 10. Tendências + +Para avaliar tendência de preço: + +- usar imóveis comparáveis; +- controlar mudanças no mix; +- ponderar cobertura; +- evitar média simples; +- usar mediana e regressões robustas quando necessário; +- separar preço de entrada, preço atual e último preço; +- mostrar intervalo de confiança ou dispersão; +- evitar extrapolação longa. + +Uma alta no preço mediano pode ocorrer porque imóveis menores desapareceram da amostra. + +--- + +## 11. Estoque e liquidez + +Métricas úteis: + +- anúncios ativos; +- imóveis prováveis ativos; +- entradas; +- saídas; +- republicações; +- tempo observado; +- reduções de preço; +- percentual com múltiplas reduções; +- concentração por corretor; +- dispersão de preços; +- taxa de reposição do estoque. + +Limitações: + +- tempo observado não é tempo real no mercado quando a coleta começou depois; +- retirada não confirma venda; +- republicação pode reiniciar o relógio artificialmente. + +--- + +## 12. Análise financeira + +### 12.1 Premissas obrigatórias + +Toda simulação deve declarar: + +- preço; +- entrada em dinheiro; +- FGTS; +- reserva preservada; +- custos de aquisição; +- reforma; +- valor financiado; +- taxa efetiva; +- CET, se disponível; +- sistema; +- prazo; +- seguros e tarifas; +- condomínio; +- IPTU; +- manutenção; +- reajuste do aluguel; +- retorno líquido dos investimentos; +- inflação; +- valorização; +- horizonte; +- custos de venda; +- estratégia de amortização. + +### 12.2 Liquidez + +Separar: + +- dinheiro; +- investimentos com liquidez; +- FGTS; +- patrimônio imobiliário; +- reserva mínima. + +Não tratar FGTS como reserva de emergência. + +### 12.3 Compra versus aluguel + +Comparar: + +- fluxo mensal; +- patrimônio líquido; +- custo de oportunidade; +- risco; +- liquidez; +- flexibilidade; +- estabilidade; +- manutenção; +- impostos; +- custos de transação. + +Mostrar cenários: + +- conservador; +- central; +- otimista. + +Não decidir apenas pelo patrimônio final médio. + +### 12.4 Estresse + +Testar: + +- juros maiores; +- renda menor; +- despesas extraordinárias; +- reforma acima do orçamento; +- vacância ou dificuldade de revenda; +- valorização baixa; +- investimento com retorno menor; +- manutenção elevada; +- redução da capacidade de poupança. + +--- + +## 13. Consórcio + +Para analisar um grupo: + +- usar histórico do próprio grupo; +- registrar assembleias; +- lances vencedores; +- lance livre; +- lance fixo; +- lance embutido; +- número de contemplações; +- saldo; +- reajuste da carta; +- taxa de administração; +- fundo de reserva; +- seguros; +- regras de uso; +- prazo; +- valor total pago. + +Não inferir probabilidade de contemplação usando apenas média histórica. + +Analisar: + +- distribuição; +- percentis; +- sazonalidade; +- número de concorrentes; +- mudanças de regra; +- eventos de renda; +- concentração de lances; +- sensibilidade ao valor ofertado. + +Relatos de gerente são hipóteses a testar. + +--- + +## 14. Relatórios por região + +### Cruzeiro + +Separar por: + +- Cruzeiro Novo e Velho; +- quadra; +- bloco; +- tipologia; +- área; +- elevador; +- garagem; +- andar; +- conservação; +- reforma. + +### Jardins Mangueiral + +Separar por: + +- QC; +- planta original de três quartos; +- área construída; +- ampliação; +- qualidade da área fechada; +- condomínio; +- posição; +- ruído; +- proximidade de serviços; +- padrão de reforma. + +Excluir casas originalmente de dois quartos. + +--- + +## 15. Relatório padrão + +### Resumo executivo + +- conclusão principal; +- confiança; +- implicação prática; +- maior risco. + +### Dados + +- fontes; +- período; +- cobertura; +- tamanho da amostra; +- exclusões; +- deduplicação. + +### Resultados + +- tabelas; +- distribuições; +- comparáveis; +- tendências; +- sensibilidade. + +### Interpretação + +- o que é sustentado; +- o que é apenas sugestivo; +- o que requer visita; +- o que requer confirmação financeira ou documental. + +### Próxima ação + +Classificar como: + +- ignorar; +- monitorar; +- aprofundar; +- visitar; +- negociar; +- candidato forte; +- decisão insuficiente. + +--- + +## 16. Reprodutibilidade + +Para cada relatório, salvar: + +```text +reports// +├── README.md +├── query.sql +├── analysis.py +├── parameters.json +├── results.csv +└── figures/ +``` + +Registrar: + +- data; +- hash do código; +- identificação do banco; +- filtros; +- versão; +- ambiente; +- contagens. + +--- + +## 17. Linguagem das conclusões + +Preferir: + +- “os anúncios observados sugerem”; +- “na amostra coletada”; +- “após deduplicação”; +- “não há evidência suficiente”; +- “a conclusão é sensível a”; +- “o dado não permite afirmar”. + +Evitar: + +- “o mercado vale”; +- “foi vendido” sem prova; +- “esta é a melhor quadra” sem critério; +- “o preço justo é” sem intervalo e comparáveis; +- “vai valorizar” como certeza; +- “o lance será contemplado” como previsão. + +--- + +## 18. Critério final + +Uma análise é boa quando ajuda a evitar: + +- comprar cedo demais; +- pagar por uma reforma disfarçada; +- confundir anúncio com transação; +- superestimar valorização; +- subestimar custos; +- comprometer liquidez; +- aceitar uma planta inadequada; +- tomar decisão por ansiedade; +- descartar um bom imóvel por uma métrica superficial. diff --git a/docs/CHAT_HANDOFF.md b/docs/CHAT_HANDOFF.md new file mode 100644 index 0000000..8d70ce8 --- /dev/null +++ b/docs/CHAT_HANDOFF.md @@ -0,0 +1,5 @@ +# Chat handoff + +## Operational transcript from the previous session + +This file consolidates the prior working session into a handoff format so the project can continue without redoing the same analysis from scratch. diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md new file mode 100644 index 0000000..f7ac56f --- /dev/null +++ b/docs/DATA_MODEL.md @@ -0,0 +1,481 @@ +# Modelo de dados — referência conceitual + +## 1. Aviso + +Este documento descreve o modelo lógico desejável para análise. + +Ele **não afirma** que o banco SQLite atual possui essas tabelas ou colunas. O esquema real deve ser inspecionado antes de qualquer consulta. + +Não migre o banco original para este modelo sem autorização. + +--- + +## 2. Entidades fundamentais + +O projeto deve distinguir cinco conceitos: + +1. **fonte**: site, portal, corretor ou canal; +2. **anúncio**: publicação identificável em uma fonte; +3. **observação**: estado do anúncio em uma data de coleta; +4. **imóvel físico**: unidade residencial que pode aparecer em vários anúncios; +5. **execução de coleta**: processo técnico que obteve os dados. + +Essa separação é essencial para séries históricas e deduplicação. + +--- + +## 3. Fonte + +Entidade conceitual: `source` + +Campos desejáveis: + +- `source_id` +- `name` +- `base_url` +- `source_type` +- `active` +- `notes` +- `created_at` + +Não usar domínio ou nome de corretor como identificador único sem normalização. + +--- + +## 4. Execução de coleta + +Entidade conceitual: `scrape_run` + +Campos desejáveis: + +- `run_id` +- `source_id` +- `started_at` +- `finished_at` +- `status` +- `pages_requested` +- `pages_succeeded` +- `pages_failed` +- `items_seen` +- `items_inserted` +- `items_updated` +- `http_status_summary` +- `scraper_version` +- `configuration_hash` +- `error_summary` + +Serve para identificar: + +- períodos sem coleta; +- mudanças de cobertura; +- falhas; +- alterações de código; +- diferenças entre ausência real e ausência causada por erro. + +--- + +## 5. Anúncio + +Entidade conceitual: `listing` + +Representa a identidade do anúncio, não cada coleta. + +Campos desejáveis: + +- `listing_id` +- `source_id` +- `source_listing_id` +- `canonical_url` +- `first_seen_at` +- `last_seen_at` +- `last_successful_seen_at` +- `status` +- `seller_type` +- `seller_name_normalized` +- `broker_registration` +- `property_candidate_id` +- `created_at` +- `updated_at` + +Chave natural potencial: + +```text +(source_id, source_listing_id) +``` + +A URL não deve ser a única chave, pois pode mudar. + +--- + +## 6. Observação histórica + +Entidade conceitual: `listing_snapshot` + +Representa o conteúdo observado em uma execução. + +Campos desejáveis: + +- `snapshot_id` +- `listing_id` +- `run_id` +- `observed_at` +- `title` +- `description` +- `price` +- `condominium_fee` +- `property_tax` +- `area_private` +- `area_useful` +- `area_built` +- `area_total` +- `bedrooms` +- `suites` +- `bathrooms` +- `parking_spaces` +- `floor` +- `elevator` +- `furnished` +- `property_type` +- `address_raw` +- `neighborhood_raw` +- `latitude` +- `longitude` +- `image_count` +- `status_raw` +- `payload_hash` +- `raw_payload_reference` + +Não sobrescrever o histórico quando um anúncio muda. + +Se os dados atuais usam uma única linha mutável por anúncio, documentar essa limitação. + +--- + +## 7. Imóvel físico candidato + +Entidade conceitual: `property_candidate` + +Representa uma hipótese de que anúncios diferentes descrevem o mesmo imóvel. + +Campos desejáveis: + +- `property_candidate_id` +- `canonical_region` +- `canonical_address` +- `canonical_unit` +- `property_type` +- `original_floor_plan` +- `dedup_confidence` +- `dedup_method` +- `review_status` +- `created_at` +- `updated_at` + +Não tratar correspondência probabilística como certeza. + +Estados sugeridos: + +- `unreviewed` +- `probable` +- `confirmed` +- `rejected` +- `ambiguous` + +--- + +## 8. Endereço e localização + +Entidade conceitual: `location` + +Campos desejáveis: + +- `location_id` +- `region` +- `administrative_region` +- `neighborhood` +- `superquadra` +- `quadra` +- `block` +- `qc` +- `street` +- `lot` +- `unit` +- `postal_code` +- `latitude` +- `longitude` +- `geocode_precision` +- `geocode_source` + +No Distrito Federal, o endereço deve permitir estruturas como: + +- SHCES; +- quadra; +- bloco; +- superquadra; +- conjunto; +- lote; +- QC; +- condomínio; +- unidade. + +Não forçar todos os endereços a um modelo de rua e número. + +--- + +## 9. Mídia + +Entidade conceitual: `listing_media` + +Campos desejáveis: + +- `media_id` +- `snapshot_id` +- `media_url` +- `media_type` +- `position` +- `content_hash` +- `perceptual_hash` +- `width` +- `height` +- `download_status` + +Hashes perceptuais de imagens podem ajudar na deduplicação, mas não devem decidir sozinhos. + +--- + +## 10. Histórico de preço + +O histórico pode ser derivado de `listing_snapshot`. + +View conceitual: + +```sql +SELECT + listing_id, + observed_at, + price, + price - LAG(price) OVER ( + PARTITION BY listing_id + ORDER BY observed_at + ) AS absolute_change +FROM listing_snapshot; +``` + +Regras: + +- ignorar repetições idênticas; +- registrar aumentos e reduções; +- não interpretar ausência como venda; +- identificar alterações simultâneas de área ou tipologia; +- detectar preços promocionais artificiais; +- separar moeda e unidades. + +--- + +## 11. Deduplicação + +### 11.1 Sinais fortes + +- mesmo identificador na fonte; +- mesmo endereço e unidade; +- mesmo conjunto de imagens; +- mesmo telefone ou corretor com descrição muito semelhante; +- mesma planta, metragem e características; +- coordenadas muito próximas; +- texto quase idêntico. + +### 11.2 Sinais moderados + +- mesma quadra e bloco; +- mesmo preço; +- mesma área; +- mesmos quartos e vagas; +- datas próximas; +- imagens parcialmente coincidentes. + +### 11.3 Sinais fracos + +- mesmo bairro; +- título genérico; +- preço arredondado; +- número de quartos isolado. + +### 11.4 Regras + +- manter o anúncio original; +- criar relacionamento de duplicidade; +- registrar método e confiança; +- permitir revisão manual; +- não fundir registros de forma irreversível; +- não usar apenas texto normalizado; +- evitar que atualizações do mesmo anúncio sejam contadas como imóveis novos. + +--- + +## 12. Normalização + +### Preço + +- armazenar como número inteiro em centavos ou valor monetário consistente; +- preservar valor bruto; +- registrar moeda; +- detectar preço por mês confundido com preço total; +- detectar valores incompletos. + +### Área + +Manter campos separados: + +- privativa; +- útil; +- construída; +- total; +- terreno. + +Nunca preencher uma área com outra apenas para completar dados. + +### Quartos + +Separar: + +- quartos; +- suítes; +- dependência; +- escritório; +- quarto reversível; +- tipologia original; +- tipologia anunciada. + +### Localização + +Preservar: + +- texto bruto; +- versão normalizada; +- componentes extraídos; +- confiança da extração. + +### Datas + +Usar ISO 8601 e timezone explícito quando relevante. + +--- + +## 13. Qualidade dos dados + +Criar métricas por fonte, período e região: + +- percentual sem preço; +- percentual sem área; +- percentual sem localização; +- percentual com preço por m² calculável; +- duplicidade estimada; +- taxa de mudança de identificador; +- cobertura diária; +- falhas de coleta; +- distribuições implausíveis; +- inconsistência entre título e campos; +- divergência entre snapshots. + +--- + +## 14. Inspeção inicial do SQLite + +Consultas somente leitura úteis: + +### Tabelas e views + +```sql +SELECT + type, + name, + tbl_name, + sql +FROM sqlite_master +ORDER BY type, name; +``` + +### Contagem aproximada de tabelas + +Gerar consultas após listar os nomes. Não concatenar nomes externos sem validação. + +### Colunas + +```sql +PRAGMA table_info('nome_da_tabela'); +``` + +### Chaves estrangeiras + +```sql +PRAGMA foreign_key_list('nome_da_tabela'); +``` + +### Índices + +```sql +PRAGMA index_list('nome_da_tabela'); +``` + +### Integridade + +```sql +PRAGMA quick_check; +``` + +`quick_check` é somente leitura, mas pode ser custoso em bancos grandes. Executar quando apropriado. + +--- + +## 15. Camada derivada recomendada + +Sem alterar o banco original, criar: + +```text +data/derived/ +├── listings_clean.parquet +├── snapshots_clean.parquet +├── property_candidates.parquet +├── price_history.parquet +├── locations_normalized.parquet +└── quality_metrics.parquet +``` + +Ou um SQLite derivado: + +```text +data/derived/analytics.sqlite +``` + +Toda derivação deve registrar: + +- data; +- versão do código; +- fonte; +- filtros; +- hash ou identificação do banco de origem; +- contagem de entrada e saída. + +--- + +## 16. Dicionário do esquema real + +Após inspecionar o banco, produzir: + +```text +docs/ACTUAL_SCHEMA.md +``` + +Esse arquivo deve conter: + +- diagrama das tabelas; +- descrição de cada coluna; +- chaves; +- índices; +- cardinalidades; +- exemplos pequenos; +- problemas conhecidos; +- correspondência com este modelo conceitual; +- decisões de interpretação. + +Não alterar `DATA_MODEL.md` para simplesmente espelhar um esquema ruim. Manter separação entre modelo conceitual e implementação real. diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md new file mode 100644 index 0000000..df23eb9 --- /dev/null +++ b/docs/PROJECT_CONTEXT.md @@ -0,0 +1,265 @@ +# Contexto do projeto — Busca de Imóveis DF + +## 1. Objetivo + +Construir uma base histórica e uma metodologia confiável para apoiar a compra de um imóvel residencial no Distrito Federal. + +O objetivo não é apenas encontrar o menor preço. A decisão deve equilibrar: + +- segurança patrimonial de longo prazo; +- adequação funcional do imóvel; +- qualidade de vida; +- liquidez; +- custo total de aquisição e manutenção; +- risco de reformas; +- impacto financeiro mensal; +- fatores psicológicos associados a possuir uma residência estável. + +A análise deve ser lenta, comparativa e baseada em evidências. Não existe urgência artificial para comprar. + +--- + +## 2. Filosofia de decisão + +### Princípios confirmados + +- Evitar comprar apenas por medo de valorização futura. +- Evitar comprometer a reserva de emergência. +- Não aceitar grandes concessões de planta, conservação ou funcionalidade apenas para alcançar um preço nominal. +- Considerar reforma como custo financeiro, operacional e psicológico. +- Preferir imóvel pronto ou com intervenções pequenas e previsíveis. +- Comparar comprar com continuar alugando. +- Considerar que a compra será provavelmente em uma região diferente da moradia atual. +- Separar segurança emocional de justificativas financeiras. +- Reconhecer o valor psicológico de possuir um imóvel físico de longo prazo, sem transformar isso em justificativa para uma compra ruim. +- Tratar preço anunciado como ponto inicial de negociação, não como valor de mercado definitivo. + +--- + +## 3. Situação residencial atual + +Últimos valores consolidados no histórico do projeto: + +- localização atual: Asa Sul, Brasília; +- imóvel alugado: aproximadamente 66 m²; +- aluguel: R$ 3.200 por mês; +- condomínio e IPTU não incluídos nesse valor; +- reajuste anual usado nas simulações anteriores: 4,5%. + +Esses números devem ser confirmados antes de novas simulações, pois podem mudar. + +A comparação aluguel versus compra deve considerar que o padrão, a localização e a tipologia dos imóveis candidatos podem ser diferentes do imóvel alugado atual. + +--- + +## 4. Regiões prioritárias + +### 4.1 Cruzeiro + +O Cruzeiro, especialmente o Cruzeiro Novo, é um dos dois principais candidatos. + +Aspectos relevantes: + +- localização central; +- acesso ao Plano Piloto; +- blocos e quadras com diferenças importantes; +- estoque antigo, com variação de conservação; +- risco de imóveis que exigem reforma; +- diferenças de estacionamento, garagem, elevador, ruído e posição do bloco; +- apartamentos de dois e três quartos podem ter áreas totais semelhantes; +- preço deve ser analisado em conjunto com planta, estado e custos de adequação. + +### 4.2 Jardins Mangueiral + +Jardins Mangueiral é o outro principal candidato. + +Aspectos relevantes: + +- casas mais novas em comparação com parte do estoque do Cruzeiro; +- sensação de segurança, silêncio e residência de longo prazo; +- proximidade de São Sebastião, onde vivem a namorada do usuário e familiares dela; +- potencial de desenvolvimento da região em horizonte de 15 a 20 anos; +- impacto de trânsito parcialmente mitigado por flexibilidade de horário e trabalho remoto; +- logística compatível com o horário escolar da filha em parte dos dias; +- necessidade de avaliar ruído de vizinhança e qualidade das adaptações internas. + +Somente casas de planta original de três quartos fazem parte do conjunto de candidatos. + +--- + +## 5. Preferências funcionais + +O imóvel ideal deve: + +- funcionar como residência de longo prazo; +- exigir pouca ou nenhuma reforma relevante; +- ter planta coerente; +- evitar circulação desperdiçada; +- ter boa iluminação e ventilação; +- oferecer espaço social útil; +- evitar duplicações de cozinha ou área gourmet sem função clara; +- ter conservação compatível com o preço; +- minimizar custos escondidos; +- preservar flexibilidade para mudanças futuras da família. + +No Jardins Mangueiral, o fechamento do quintal não é automaticamente negativo. A análise deve avaliar se a área foi convertida em espaço realmente útil, ventilado e integrado. + +--- + +## 6. Referência financeira + +### Último retrato consolidado + +Valores informados no histórico, sujeitos a atualização: + +- recursos líquidos: R$ 222.000; +- saldo de FGTS: R$ 130.000; +- reserva mínima desejada após a compra: R$ 50.000; +- capacidade de poupança: R$ 15.000 a cada seis meses; +- faixa de preço mais compatível com os imóveis desejados: aproximadamente R$ 650.000 a R$ 750.000; +- faixa anterior de referência: R$ 600.000. + +Não use esses valores como atuais sem registrar a data da análise e pedir confirmação quando a conclusão depender deles. + +### Regras financeiras + +- FGTS não deve ser tratado como liquidez disponível para emergências. +- Custos de compra devem ser adicionados ao preço: + - ITBI; + - registro; + - escritura quando aplicável; + - avaliação; + - tarifas; + - mudança; + - reparos; + - mobiliário; + - eventual reforma; + - custos de oportunidade. +- A reserva mínima de R$ 50.000 não deve ser consumida pela entrada, custos ou reforma. +- A prestação não deve ser analisada isoladamente. +- Comparar: + - aluguel; + - condomínio; + - IPTU; + - seguros; + - manutenção; + - juros; + - amortização; + - custo de oportunidade da entrada; + - valorização; + - reajuste do aluguel; + - liquidez do imóvel. +- Distinguir patrimônio acumulado de fluxo de caixa mensal. + +--- + +## 7. Financiamento e consórcio + +### Financiamento + +Simulações anteriores consideraram diferentes cenários de: + +- SAC; +- prazos de 20, 30 e até 35 anos; +- juros próximos de 9% ao ano em alguns cenários; +- amortizações periódicas com FGTS; +- amortizações extraordinárias em dinheiro. + +Toda nova simulação deve explicitar: + +- taxa nominal e efetiva; +- indexador; +- CET; +- sistema de amortização; +- prazo; +- entrada; +- custos acessórios; +- uso de FGTS; +- estratégia de amortização; +- inflação; +- retorno líquido alternativo; +- horizonte de permanência. + +### Pró-cotista + +A estratégia não deve assumir que o Pró-cotista estará disponível. O usuário já percebeu que provavelmente não receberá essa linha. + +### Consórcio + +O consórcio do Banco do Brasil foi considerado como alternativa. + +Informações recebidas do gerente, tratadas como relatos e não como garantias: + +- grupos com desconto podem atrair participantes dispostos a oferecer lances elevados; +- agosto e setembro podem ser meses relativamente melhores para ofertar lance; +- outubro pode ficar mais competitivo por coincidir com pagamento de PLR de funcionários do BB; +- muitos participantes dos consórcios do BB seriam funcionários do próprio banco. + +Essas afirmações precisam ser verificadas com dados do grupo sempre que forem usadas. Não extrapolar um grupo para todos os grupos. + +A estratégia atual é não agir com pressa e observar os percentuais de lances ao longo do tempo. + +--- + +## 8. Fatores familiares e logísticos + +- O usuário tem uma filha em idade escolar. +- A namorada mora em São Sebastião e tem um filho. +- Jardins Mangueiral pode reduzir a distância para a namorada e a família dela. +- O usuário tem flexibilidade de trabalho e de horário em parte da semana. +- A logística familiar deve ser considerada, mas não deve justificar um imóvel inadequado. +- A compra deve continuar válida mesmo se circunstâncias relacionais mudarem. + +--- + +## 9. Horizonte + +A decisão deve ser avaliada como residência e patrimônio de longo prazo. + +Questões centrais: + +- O imóvel continuará funcional em 10, 15 ou 20 anos? +- A localização atende a rotina sem depender de uma única circunstância atual? +- O custo mensal permanece confortável em cenários adversos? +- Existe reserva para manutenção, saúde, carro e emergências? +- A planta permite adaptação? +- A revenda é razoável? +- A compra reduz ou aumenta a fragilidade financeira? + +--- + +## 10. Questões em aberto + +Manter estas questões explícitas até que os dados permitam respondê-las: + +- valor atual disponível em dinheiro e FGTS; +- limite confortável de prestação; +- prazo máximo desejável; +- custo total aceitável de aquisição; +- peso relativo entre Cruzeiro e Jardins Mangueiral; +- blocos e quadras prioritários no Cruzeiro; +- QCs prioritárias no Mangueiral após visitas; +- tolerância a imóveis sem elevador ou garagem; +- impacto real do trânsito; +- custo de manutenção de casas versus apartamentos; +- tamanho mínimo funcional; +- horizonte provável de permanência; +- estratégia final entre financiamento, consórcio e espera. + +--- + +## 11. Fonte de verdade + +Este documento consolida preferências e premissas, mas não substitui uma confirmação recente quando a decisão depender de: + +- saldo financeiro; +- renda; +- taxas bancárias; +- regras de FGTS; +- custos cartorários; +- disponibilidade de crédito; +- preços atuais; +- condições de consórcio; +- situação familiar ou logística. + +Toda informação temporal deve carregar data de referência. diff --git a/docs/PROPERTY_CRITERIA.md b/docs/PROPERTY_CRITERIA.md new file mode 100644 index 0000000..8fd2ced --- /dev/null +++ b/docs/PROPERTY_CRITERIA.md @@ -0,0 +1,438 @@ +# Critérios de avaliação dos imóveis + +## 1. Estrutura da avaliação + +Classificar cada critério como: + +- **eliminatório**: imóvel deve ser excluído; +- **forte**: pode inviabilizar a compra mesmo com bom preço; +- **moderado**: influencia comparação e negociação; +- **informativo**: deve ser registrado, mas não decide sozinho. + +Não criar pesos numéricos arbitrários sem aprovação. Quando usar pontuação, mostrar os pesos, a justificativa e o efeito de cada critério. + +--- + +## 2. Critérios eliminatórios gerais + +Excluir ou colocar em quarentena analítica quando houver: + +- documentação incompatível ou não esclarecida; +- área, tipologia ou localização materialmente divergentes; +- planta que não atenda às necessidades de longo prazo; +- preço total incompatível com a preservação da reserva; +- problemas graves de umidade, infiltração ou estrutura; +- ruído intolerável identificado em visita; +- custo oculto que destrua a vantagem aparente; +- anúncio sem informação mínima para comparação; +- indícios relevantes de fraude ou inconsistência. + +Um imóvel não deve ser eliminado apenas por falta de dados no anúncio. Nesse caso, classifique-o como pendente de verificação. + +--- + +## 3. Critérios gerais de qualidade + +### 3.1 Planta e funcionalidade + +Avaliar: + +- distribuição dos cômodos; +- privacidade dos quartos; +- largura de circulação; +- áreas mortas; +- integração social; +- capacidade de mobiliamento; +- armazenamento; +- número e localização de banheiros; +- ventilação cruzada; +- iluminação natural; +- possibilidade de adaptação futura; +- conflito entre portas, móveis e circulação; +- uso real de varandas, quintais e áreas gourmet. + +### 3.2 Estado de conservação + +Registrar separadamente: + +- estrutura; +- elétrica; +- hidráulica; +- revestimentos; +- esquadrias; +- telhado; +- impermeabilização; +- pintura; +- armários; +- climatização; +- equipamentos; +- sinais de reforma improvisada. + +Não resumir tudo como “reformado”. Uma reforma estética pode esconder sistemas antigos. + +### 3.3 Custos de adequação + +Estimar: + +- reparos imediatos; +- intervenções em até dois anos; +- mobiliário obrigatório; +- eletrodomésticos; +- mudança; +- custo de desocupação, quando aplicável; +- custo de tempo e transtorno; +- contingência de obra. + +Aplicar margem de segurança para reformas. Não usar apenas o orçamento mais otimista. + +### 3.4 Conforto ambiental + +Avaliar em horários diferentes: + +- incidência solar; +- calor; +- ventilação; +- iluminação; +- ruído; +- odores; +- privacidade; +- proximidade de vias; +- fluxo de pedestres; +- iluminação noturna; +- segurança percebida; +- drenagem e risco de alagamento. + +### 3.5 Liquidez e revenda + +Considerar: + +- tipologia; +- padrão da região; +- facilidade de financiamento; +- regularidade documental; +- garagem; +- elevador; +- posição; +- conservação; +- faixa de preço; +- público comprador provável; +- excesso de personalização; +- limitações permanentes de planta. + +--- + +## 4. Cruzeiro Novo + +### 4.1 Pontos a registrar + +- quadra; +- bloco; +- posição no bloco; +- andar; +- orientação solar; +- vista; +- proximidade de vias e comércio; +- ruído; +- elevador; +- garagem ou estacionamento; +- estado das áreas comuns; +- taxa de condomínio; +- obras previstas; +- idade e conservação do prédio; +- prumadas; +- elétrica; +- fachada; +- acessibilidade; +- área privativa e área total; +- número original de quartos; +- alterações de planta; +- ocupação atual; +- situação documental. + +### 4.2 Dois versus três quartos + +No Cruzeiro, apartamentos anunciados como dois ou três quartos podem ter áreas semelhantes. + +A análise deve verificar: + +- planta original; +- quarto revertido; +- dependência transformada; +- sala dividida; +- ventilação do cômodo adicional; +- dimensão real dos quartos; +- impacto na circulação; +- valor de revenda. + +Não classificar automaticamente três quartos como superior se o terceiro cômodo for inadequado. + +### 4.3 Elevador e garagem + +Ausência de elevador ou garagem é uma concessão relevante, não um detalhe. + +Registrar: + +- andar; +- número de lances de escada; +- acessibilidade futura; +- facilidade de mudança; +- uso por idosos; +- disponibilidade real de estacionamento; +- segurança ao estacionar; +- regras do condomínio. + +O efeito no preço deve ser analisado com imóveis comparáveis. + +### 4.4 Reforma + +Imóveis antigos podem exigir modernização invisível no anúncio. + +Durante a visita, verificar: + +- quadro elétrico; +- aterramento; +- tomadas; +- tubulações; +- registros; +- pressão de água; +- vazamentos; +- esquadrias; +- ruído entre unidades; +- estado da laje; +- gás; +- ventilação de banheiros; +- infiltrações em fachadas; +- histórico de obras do bloco. + +--- + +## 5. Jardins Mangueiral + +### 5.1 Filtro de tipologia + +Somente considerar casas com **planta de três quartos**. + +Casas originalmente de dois quartos devem ser excluídas, mesmo quando reformadas ou anunciadas como tendo três quartos. + +A validação pode usar: + +- planta original; +- posição da escada; +- dimensões; +- fachada; +- implantação; +- documentos; +- comparação com unidades padrão; +- fotografias. + +### 5.2 Quintal fechado e ampliações + +A perda do quintal original não é automaticamente um problema. + +A conversão deve ser considerada positiva quando cria: + +- sala ampliada; +- espaço de convivência; +- ambiente de refeições; +- escritório; +- área social ventilada; +- integração útil com a casa; +- armazenamento funcional. + +Penalizar quando a conversão: + +- duplica cozinha ou cooktop sem necessidade; +- cria circulação inútil; +- reduz ventilação e iluminação; +- aumenta calor; +- usa material de baixa qualidade; +- compromete impermeabilização; +- dificulta manutenção; +- cria ambiente sem função clara; +- prejudica privacidade; +- ocupa todo o espaço sem compensação funcional. + +### 5.3 Área gourmet + +Não atribuir valor automático ao rótulo “área gourmet”. + +Avaliar: + +- frequência provável de uso; +- integração com a cozinha; +- redundância de equipamentos; +- exaustão; +- ventilação; +- limpeza; +- circulação; +- capacidade de receber pessoas; +- qualidade da execução; +- manutenção. + +Um segundo cooktop ao lado da cozinha principal pode ser redundante. + +### 5.4 QCs e acesso a serviços + +Relatos de moradores usados como referência anedótica: + +- QCs 8 e 9: percebidas como mais fortes para acesso prático a comércio e serviços; +- QCs 6 e 7: alternativas consideradas; +- QCs 10 e 11: também consideradas boas. + +Essas preferências não substituem verificação de campo. + +Para cada QC, medir ou registrar: + +- distância a comércio; +- acesso a mercado, farmácia e serviços; +- saída viária; +- tempo em horários relevantes; +- transporte; +- topografia; +- iluminação; +- tráfego interno; +- ruído; +- estacionamento; +- áreas comuns; +- manutenção do condomínio. + +### 5.5 Ruído e vizinhança + +Avaliar explicitamente: + +- paredes compartilhadas; +- ruído de passos e móveis; +- música; +- festas; +- cães; +- crianças; +- áreas de convivência; +- quadras esportivas; +- comércio; +- portarias; +- vias internas; +- motos; +- coleta de lixo; +- obras; +- templos; +- escolas; +- bares. + +Fazer visitas em mais de um horário quando possível, incluindo noite ou fim de semana. + +Perguntar aos moradores sem depender apenas do corretor. + +### 5.6 Reformas e regularidade + +Verificar: + +- fechamento de área externa; +- cobertura; +- drenagem; +- calhas; +- telhado; +- impermeabilização; +- ventilação; +- padrão elétrico; +- hidráulica; +- alterações estruturais; +- aprovação condominial; +- regularidade documental; +- impacto em seguro e financiamento. + +--- + +## 6. Checklist de visita + +### Antes da visita + +- salvar anúncio e data; +- registrar preço; +- registrar condomínio e IPTU; +- conferir área e quartos; +- buscar histórico do anúncio; +- verificar anúncios duplicados; +- mapear entorno; +- listar dúvidas. + +### Durante a visita + +- fotografar com autorização; +- registrar ruído com janelas abertas e fechadas; +- testar torneiras, descargas, chuveiros e registros; +- observar umidade e odores; +- verificar tomadas e quadro; +- medir cômodos críticos; +- conferir incidência solar; +- observar circulação; +- avaliar armazenamento; +- verificar sinal de celular e internet; +- examinar garagem ou estacionamento; +- conversar com porteiro ou moradores quando apropriado; +- perguntar motivo da venda; +- perguntar tempo de anúncio; +- perguntar reformas e documentação; +- verificar taxas extraordinárias. + +### Depois da visita + +- registrar impressão no mesmo dia; +- separar fatos de percepções; +- estimar custos; +- comparar com alternativas reais; +- listar riscos; +- definir preço máximo; +- registrar pendências documentais; +- evitar decisão motivada apenas por entusiasmo. + +--- + +## 7. Negociação + +O preço máximo deve considerar: + +- valor comparável; +- estado; +- custos de compra; +- reforma; +- liquidez; +- concessões; +- risco; +- tempo de anúncio; +- histórico de preço; +- condição de pagamento; +- custo de oportunidade. + +Não negociar com base apenas no percentual de desconto. + +O desconto nominal deve ser comparado ao custo real de corrigir as deficiências. + +--- + +## 8. Saída padrão por imóvel + +Cada ficha analítica deve conter: + +- URL e identificador; +- data de coleta; +- região, quadra, bloco ou QC; +- tipologia original; +- área; +- quartos; +- garagem; +- elevador; +- preço; +- condomínio; +- IPTU; +- preço por m² válido ou não; +- histórico de preço; +- possíveis duplicatas; +- pontos fortes; +- pontos fracos; +- reformas; +- custo estimado de adequação; +- riscos; +- pendências; +- posição relativa aos comparáveis; +- preço máximo sugerido, quando houver dados; +- decisão: excluir, monitorar, visitar, negociar ou candidato forte. diff --git a/headers.txt b/headers.txt new file mode 100644 index 0000000..a4947da --- /dev/null +++ b/headers.txt @@ -0,0 +1,22 @@ +HTTP/2 403 +date: Mon, 13 Jul 2026 12:29:34 GMT +content-type: text/html; charset=UTF-8 +content-length: 5753 +accept-ch: Sec-CH-UA-Bitness, Sec-CH-UA-Arch, Sec-CH-UA-Full-Version, Sec-CH-UA-Mobile, Sec-CH-UA-Model, Sec-CH-UA-Platform-Version, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform, Sec-CH-UA, UA-Bitness, UA-Arch, UA-Full-Version, UA-Mobile, UA-Model, UA-Platform-Version, UA-Platform, UA +cf-mitigated: challenge +content-security-policy: default-src 'none'; script-src 'nonce-NDFRcU4DySSBatDK11iuTs' 'unsafe-eval' https://challenges.cloudflare.com; script-src-attr 'none'; style-src 'unsafe-inline'; img-src 'self' https://challenges.cloudflare.com; connect-src 'self' https://challenges.cloudflare.com; frame-src 'self' https://challenges.cloudflare.com blob:; child-src 'self' https://challenges.cloudflare.com blob:; worker-src blob:; form-action http: https:; base-uri 'self' +server: cloudflare +critical-ch: Sec-CH-UA-Bitness, Sec-CH-UA-Arch, Sec-CH-UA-Full-Version, Sec-CH-UA-Mobile, Sec-CH-UA-Model, Sec-CH-UA-Platform-Version, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform, Sec-CH-UA, UA-Bitness, UA-Arch, UA-Full-Version, UA-Mobile, UA-Model, UA-Platform-Version, UA-Platform, UA +cross-origin-embedder-policy: require-corp +cross-origin-opener-policy: same-origin +cross-origin-resource-policy: same-origin +origin-agent-cluster: ?1 +permissions-policy: accelerometer=(),camera=(),clipboard-read=(),clipboard-write=(),geolocation=(),gyroscope=(),hid=(),magnetometer=(),microphone=(),payment=(),publickey-credentials-get=(),screen-wake-lock=(),serial=(),sync-xhr=(),usb=(),xr-spatial-tracking=* +referrer-policy: same-origin +server-timing: chlray;desc="a1a84b426c72f17f" +x-content-type-options: nosniff +x-frame-options: SAMEORIGIN +report-to: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=RJh2QutRJQCG335sylJHf3W5WhCN3A6QHLF%2FYrIaRQuW0iICamlOrJMou8pb%2BZtSwNZ7YHwnR8mq2PmVZV8LhatfWgSaoMSttSNrqvk9gW5uhc7qag5fNebLVMYN7iMuRjh8b4z2"}]} +nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800} +cf-ray: a1a84b426c72f17f-GRU + diff --git a/house-quest.code-workspace b/house-quest.code-workspace new file mode 100644 index 0000000..876a149 --- /dev/null +++ b/house-quest.code-workspace @@ -0,0 +1,8 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} \ No newline at end of file diff --git a/reports/cruzeiro_top10/README.md b/reports/cruzeiro_top10/README.md new file mode 100644 index 0000000..7bb3b99 --- /dev/null +++ b/reports/cruzeiro_top10/README.md @@ -0,0 +1,172 @@ +# Ranking preliminar — apartamentos no Cruzeiro Novo + +**Data de referência do snapshot:** 17/07/2026 +**Relatório revisado:** 17/07/2026 +**Unidade principal:** imóvel físico provável, não snapshot nem anúncio isolado +**Finalidade:** priorizar visitas; não estimar preço negociado nem substituir vistoria + +## Pergunta + +Quais são os dez imóveis do Cruzeiro Novo que mais merecem atenção segundo as preferências e regras do projeto, dando às fotografias importância comparável à dos dados estruturados? + +## Universo e método + +Foram avaliados os 65 anúncios encontrados pela busca: + +`https://www.dfimoveis.com.br/venda/df/cruzeiro/novo/apartamento/3-quartos` + +A coleta cobre um único dia e um único portal. O banco foi aberto somente em leitura. Os campos foram complementados com os HTMLs brutos, pois localização e descrição no SQLite têm limitações já documentadas em `docs/ACTUAL_SCHEMA.md`. + +Na coleta concluída em 17/07, os 65 anúncios do Cruzeiro permaneceram ativos e nenhum anúncio novo entrou. O único preço alterado foi o do anúncio `1372539`, de R$ 572.500 para R$ 569.000; ele continua fora do ranking porque a descrição confirma planta de três quartos convertida em dois. Assim, a ordem foi mantida após revisão das mudanças materiais. + +Não foi criada pontuação numérica, pois os pesos não foram aprovados. A ordem é uma **prioridade de visita ajustada por risco**, aplicando: + +1. critérios eliminatórios e inconsistências; +2. planta funcional de três quartos; +3. indícios de reforma relevante ou custos escondidos; +4. iluminação, ventilação, distribuição e conservação visíveis; +5. andar, elevador e garagem; +6. preço, área e condomínio anunciados; +7. qualidade e completude da evidência. + +### Revisão fotográfica + +Em 17/07/2026, a primeira foto dos dez escolhidos foi auditada especificamente para marcas de “vendido” ou “negócio fechado”. Nenhuma das dez apresentou esse sinal. + +A primeira passagem examinou até 16 imagens de cada um dos 65 anúncios, totalizando 1.029 arquivos. Depois, foram examinadas todas as 532 fotos dos 17 finalistas. Descontada a sobreposição entre as etapas, 1.289 arquivos distintos foram efetivamente vistos. + +As imagens foram usadas para observar conservação, coerência da reforma, luz, ventilação aparente, tamanho relativo dos quartos, circulação, integração social, cozinha, banheiros, área de serviço, fachada e estacionamento. Fotografias não permitem validar elétrica, hidráulica, infiltração, ruído, estrutura ou documentação. + +Muitos anúncios incluem, depois das fotos do imóvel, imagens genéricas do portal, logos e até cartões de outros imóveis marcados como “vendido”. Esses ativos foram desconsiderados na avaliação visual do apartamento. + +## Ranking + +### 1. Anúncio 1345146 — SHCES 1207, bloco A + +- **Anunciado:** R$ 670.000; 65 m²; condomínio R$ 605; 3 quartos, 1 suíte; 4º andar. +- **Por que lidera:** é o conjunto mais equilibrado entre reforma pronta, planta preservada e acessibilidade. O anúncio declara elevador, diferencial forte para uso de longo prazo. +- **Fotos:** reforma coerente e bem conservada; cozinha planejada, área de serviço separada, dois banheiros atualizados, três quartos reconhecíveis e boa luz. Os dormitórios e a área de serviço parecem compactos, mas funcionais. +- **Pendências:** confirmar funcionamento/obras do elevador, posição solar, área da matrícula, estacionamento/garagem e sistemas elétrico e hidráulico. +- **Ação:** candidato forte; visitar primeiro. +- **URL:** https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-1207-bloco-a-1345146 + +### 2. Anúncio 1232283 — SHCES 309, bloco B + +- **Anunciado:** R$ 600.000; 70 m²; condomínio R$ 497; 3 quartos, 1 suíte; 3º andar; 2 vagas; nascente e vazado. +- **Por que está no topo:** maior área entre os primeiros colocados, preço competitivo, duas vagas anunciadas e reforma recente. O anúncio informa vista livre e ocupação imediata. +- **Fotos:** ambientes claros, armários abundantes, dois banheiros modernos e reforma consistente. Um dos dormitórios parece estreito; a qualidade da circulação precisa ser sentida presencialmente. +- **Pendências:** confirmar se as duas vagas são privativas/vinculadas e se há elevador — ele não é informado. No 3º andar, sua ausência é concessão forte. +- **Ação:** candidato forte; visitar, condicionado à confirmação das vagas e acessibilidade. +- **URL:** https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-309-bloco-b-1232283 + +### 3. Anúncio 1357415 — SHCES 1109 + +- **Anunciado:** R$ 630.000; 67,2 m²; condomínio R$ 350; 3 quartos, 1 suíte; 4º andar; nascente e vazado. +- **Por que se destaca:** reforma visualmente mais homogênea que a maioria, bloco revitalizado, vista livre, boa ventilação e condomínio anunciado baixo. +- **Fotos:** sala social bem resolvida, cozinha planejada, marcenaria útil, dois banheiros atualizados, quartos claros e conservação convincente. Não aparecem sinais evidentes de obra imediata. +- **Pendências:** não há elevador informado e o imóvel está no 4º andar; confirmar isso antes da visita. Verificar estacionamento, escopo real da reforma e autenticidade do condomínio de R$ 350. +- **Ação:** candidato forte se a escada for aceitável; caso contrário, rebaixar fortemente. +- **URL:** https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-1109-1357415 + +### 4. Anúncio 1357633 — SHCES 309 + +- **Anunciado:** R$ 600.000; 64 m²; 3 quartos; 2º andar; nascente, vazado e de canto. +- **Por que se destaca:** o anúncio declara reforma de elétrica, hidráulica, pisos e acabamentos, além de documentação regular. O 2º andar reduz o impacto provável da ausência de elevador. +- **Fotos:** acabamento moderno e uniforme, boa marcenaria, iluminação natural e três dormitórios aparentes. A planta permanece convencional e funcional; não há suíte anunciada, mas há banheiro de serviço. +- **Pendências:** condomínio, IPTU, garagem e elevador estão ausentes. Confirmar notas/escopo da reforma e ventilação dos banheiros. +- **Ação:** candidato forte; visitar. +- **URL:** https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-309-1357633 + +### 5. Anúncio 1376958 — SHCES 911, bloco B + +- **Anunciado:** R$ 599.000; 62 m²; condomínio R$ 600; 3 quartos, 1 suíte; 2º andar; nascente e vazado. +- **Por que se destaca:** combina preço baixo no grupo reformado, andar razoável, suíte e ventilação cruzada declarada. +- **Fotos:** sala ampla para a metragem, cozinha e banheiros atualizados, boa vista e luz. O quarto com beliche aparenta ser pequeno, mas utilizável; a área de serviço é funcional, embora simples. +- **Pendências:** prédio sem elevador segundo a descrição e sem garagem informada. Confirmar área privativa, ruído da via visível e o que está incluído no condomínio. +- **Ação:** visitar. +- **URL:** https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-911-bloco-b-1376958 + +### 6. Anúncio 1345903 — SHCES 1303 + +- **Anunciado:** R$ 565.000; 64,5 m²; condomínio R$ 529; 3 quartos, 1 suíte; 1º andar; vazado. +- **Por que se destaca:** menor preço do top 10, acessibilidade melhor por estar no 1º andar, suíte e informação explícita de elétrica e esquadrias novas. +- **Fotos:** imóvel cuidado e aparentemente habitável sem obra imediata. Banheiros estão atualizados, mas cozinha, pisos e parte da marcenaria têm linguagem mais antiga. A planta parece íntegra e os três quartos são visíveis. +- **Pendências:** somente estacionamento público; confirmar ventilação, ruído do 1º andar, hidráulica e segurança das janelas. +- **Ação:** visitar com foco em custo real de modernização opcional. +- **URL:** https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-1303-1345903 + +### 7. Anúncio 1339324 — SHCES 703 + +- **Anunciado:** R$ 665.000; 64 m²; condomínio R$ 390; 3 quartos, 1 suíte; 3º andar. +- **Por que se destaca:** reforma pronta, climatização, condomínio anunciado baixo e excelente aproveitamento de marcenaria. +- **Fotos:** uma das melhores apresentações: integração social coerente, cozinha funcional, dois banheiros bem executados, três quartos utilizáveis, boa iluminação e áreas comuns cuidadas. A circulação íntima parece estreita. +- **Pendências:** elevador, garagem, posição solar e escopo técnico da reforma não são informados. O 3º andar sem elevador seria concessão relevante. +- **Ação:** visitar após esclarecer acessibilidade e estacionamento. +- **URL:** https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-703-1339324 + +### 8. Imóvel provável dos anúncios 1370889 e 1375200 — SHCES 207 + +- **Anunciado:** R$ 695.000; 62 m²; condomínio entre R$ 435 e R$ 453; 3 quartos, 1 suíte. Um anúncio informa 2º andar. +- **Por que se destaca:** prédio reformado, planta social integrada, suíte e condição pronta para morar. +- **Fotos:** os dois anúncios mostram o mesmo apartamento com alta confiança visual. Sala/cozinha integradas funcionam bem, há área de serviço independente, dois banheiros atualizados e três dormitórios. A reforma parece consistente, embora bastante personalizada. +- **Pendências:** confirmar formalmente que os anúncios são da mesma unidade, andar, orientação solar, área, garagem e elevador. A duplicidade não deve inflar a oferta. +- **Ação:** visitar uma única vez após confirmar o anúncio primário. +- **URLs:** https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-207-1370889 e https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-207-1375200 + +### 9. Anúncio 1372268 — SHCES 305, bloco I + +- **Anunciado:** R$ 595.000; 62,61 m²; condomínio R$ 460; 3 quartos; 1º andar; canto e vazado; 2 vagas. +- **Por que entra:** duas vagas anunciadas e 1º andar são diferenciais duráveis. O preço deixa margem maior para atualização que os reformados mais caros. +- **Fotos:** planta convencional, clara e aparentemente conservada; cozinha e banheiros são funcionais, porém mais antigos. Não há sinal visual de obra estrutural, mas a modernização seria maior do que nos primeiros colocados. +- **Pendências:** confirmar documentalmente as duas vagas, ventilação, elétrica, hidráulica e orçamento real de adequação. +- **Ação:** visitar como alternativa de valor; não assumir que o desconto cobre reforma sem orçamento. +- **URL:** https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-305-bloco-i-1372268 + +### 10. Anúncio 1373131 — SHCES 305 + +- **Anunciado:** R$ 725.000; 65 m²; condomínio R$ 420; 3 quartos; 2º andar; nascente e vazado; sem elevador. +- **Por que entra:** é a reforma tecnicamente mais bem descrita: elétrica e hidráulica renovadas, projeto de arquiteta, boa orientação e ventilação cruzada. +- **Fotos:** execução visual limpa e consistente, cozinha e banheiro novos, três quartos preservados e boa luminosidade. Não possui suíte anunciada e a linguagem totalmente branca deve ser avaliada sem o efeito de grande-angular. +- **Pendências:** não há garagem informada; preço é alto para unidade sem suíte, elevador ou vaga. Confirmar documentação da reforma e medidas reais dos ambientes. +- **Ação:** visitar somente se a condição pronta justificar o prêmio pedido. +- **URL:** https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-305-1373131 + +## Quase entraram + +- **[1382466](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-101-bloco-c-1382466), R$ 650.000:** boa reforma aparente e uma vaga, mas faltam fotos da cozinha e informações de andar, elevador e orientação; aprofundar antes de substituir alguém do top 10. +- **[1290510](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-911-bloco-a-1290510), R$ 640.000:** três quartos com suíte, vazado e reformado, mas fica no 4º andar sem elevador informado e os valores de condomínio/IPTU foram claramente extraídos como R$ 1; precisa de confirmação. +- **[1371813](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-1109-bloco-d-1371813), R$ 709.000:** reforma moderna e 68 m², mas o conjunto de fotos é curto e não esclarece bem três quartos, área de serviço, andar, garagem ou elevador. + +## Quarentenas e exclusões relevantes + +- **1373346:** é um imóvel no Noroeste capturado indevidamente pela busca; excluído. +- **1212351, 1338088, 1259968, 1315328 e 1372539:** planta de três quartos convertida em dois ou sala/escritório; não priorizados para residência de longo prazo. +- **1313649:** anunciado na descrição como dois quartos apesar do campo estruturado indicar três; condomínio de R$ 1.172; excluído até esclarecer planta. +- **1370861:** três quartos originais transformados em suíte master e escritório; não atende à funcionalidade desejada. +- **1365929, 1161400, 1278826 e similares:** fotos e descrição indicam reforma relevante, sem desconto claramente suficiente frente às alternativas prontas. +- **1366910, 1372290 e 1383534:** 22–35 fotos visualmente similares indicam o mesmo imóvel provável, mas os anúncios divergem entre SHCES 207 e SHCES 303 e entre 63 e 74 m². O conjunto seria competitivo, porém permanece em quarentena até confirmar endereço, área e anúncio válido. +- **1370948, 1371797 e 1372167:** grupo provável de anúncios duplicados, sem vantagem suficiente sobre o top 10. +- **1357834 e 1358242:** duplicata provável; há ainda um terceiro anúncio visualmente semelhante, 1382413. +- **1372907, 1348535 e 1380850:** elevador, garagem ou área podem ser atrativos, mas os preços de R$ 820 mil a R$ 990 mil não vêm acompanhados de vantagem funcional suficiente para superar as opções anteriores. A conclusão financeira depende de atualização dos saldos e limites do usuário. + +Uma imagem com selo “vendido” foi tratada como sinal de inconsistência, não como prova de venda, porque muitos anúncios contêm ativos genéricos de outros imóveis. + +## Limitações e verificações para visita + +O ranking é sensível a cinco informações ainda não confirmadas: tolerância pessoal a escadas, existência jurídica das vagas, conceito correto de área, qualidade invisível das reformas e limite financeiro atual. Antes de qualquer proposta: + +1. conferir matrícula, área privativa e vagas; +2. testar elétrica, hidráulica, pressão, esquadrias e sinais de infiltração; +3. medir os três quartos e validar que permanecem funcionais; +4. visitar em horário de tráfego e à noite para ruído e estacionamento; +5. verificar elevador, obras e atas do condomínio; +6. confirmar condomínio, IPTU, taxas extras e orientação solar; +7. comparar preço máximo somente depois de atualizar recursos, FGTS, reserva e prestação confortável. + +## Reprodutibilidade + +- `analysis.py`: extrai banco e HTMLs em leitura, produzindo TSV. +- `photo_similarity.py`: sinaliza duplicatas por hash visual simples; os resultados exigem revisão humana. +- `query.sql`: consultas principais de universo e qualidade. +- `parameters.json`: parâmetros e fingerprint do snapshot. +- `results.csv`: ranking em formato tabular. diff --git a/reports/cruzeiro_top10/__pycache__/analysis.cpython-314.pyc b/reports/cruzeiro_top10/__pycache__/analysis.cpython-314.pyc new file mode 100644 index 0000000..c945a61 Binary files /dev/null and b/reports/cruzeiro_top10/__pycache__/analysis.cpython-314.pyc differ diff --git a/reports/cruzeiro_top10/__pycache__/photo_similarity.cpython-314.pyc b/reports/cruzeiro_top10/__pycache__/photo_similarity.cpython-314.pyc new file mode 100644 index 0000000..e7f824e Binary files /dev/null and b/reports/cruzeiro_top10/__pycache__/photo_similarity.cpython-314.pyc differ diff --git a/reports/cruzeiro_top10/analysis.py b/reports/cruzeiro_top10/analysis.py new file mode 100644 index 0000000..dacea23 --- /dev/null +++ b/reports/cruzeiro_top10/analysis.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Read-only extraction helper for the Cruzeiro ranking. + +The database is opened in a consistent read transaction so WAL content is +included. The script only prints a TSV and never updates the database. +""" + +from __future__ import annotations + +import gzip +import argparse +import re +import sqlite3 +import sys +from pathlib import Path + +from bs4 import BeautifulSoup + + +ROOT = Path(__file__).resolve().parents[2] +DB_PATH = ROOT / "dfimoveis_data" / "dfimoveis.sqlite3" +DATA_ROOT = DB_PATH.parent +SEARCH_FRAGMENT = "/cruzeiro/novo/" + + +def clean(value: str | None) -> str: + return re.sub(r"\s+", " ", value or "").strip() + + +def sanitize_description(value: str) -> str: + value = re.sub(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b", "[email]", value) + value = re.sub( + r"(?:\(\d{2}\)|\b\d{2}\b)\s*(?:\d[\s.-]*){5,11}\d", + "[telefone]", + value, + ) + value = re.sub(r"\bCRECI\b.{0,30}", "", value, flags=re.IGNORECASE) + return clean(value) + + +def parse_html(relative_path: str) -> tuple[str, str, str]: + with gzip.open(DATA_ROOT / relative_path, "rt", encoding="utf-8", errors="replace") as fh: + soup = BeautifulSoup(fh.read(), "lxml") + + description_node = soup.select_one("div.assined-imv") + description = "" + if description_node: + for node in description_node.select("span, a, button"): + node.decompose() + description = sanitize_description(description_node.get_text(" ", strip=True)) + + details: list[str] = [] + for item in soup.select("ul.details-text li"): + text = clean(item.get_text(" ", strip=True)) + if text and text not in details: + details.append(text) + + address_node = soup.select_one('[itemprop="address"]') + address = clean(address_node.get_text(" ", strip=True) if address_node else "") + return description, " | ".join(details), address + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--ids-only", action="store_true") + args = parser.parse_args() + connection = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) + connection.execute("PRAGMA query_only = ON") + connection.execute("BEGIN") + rows = connection.execute( + """ + SELECT + l.listing_id, l.url, l.price_brl, l.condominium_brl, l.iptu_brl, + l.area_m2, l.bedrooms, l.suites, l.parking_spaces, + l.raw_html_path, COUNT(p.source_url) AS photo_count + FROM listings AS l + JOIN listing_searches AS s USING (listing_id) + LEFT JOIN photos AS p USING (listing_id) + WHERE instr(s.search_url, ?) > 0 + AND l.inactive_at IS NULL + GROUP BY l.listing_id + ORDER BY l.price_brl, l.listing_id + LIMIT 100 + """, + (SEARCH_FRAGMENT,), + ).fetchall() + connection.close() + + if args.ids_only: + for row in rows: + print(row[0]) + return 0 + + print( + "listing_id\tprice_brl\tcondominium_brl\tiptu_brl\tarea_m2\tbedrooms\t" + "suites\tparking_spaces\tphoto_count\taddress\tdetails\tdescription\turl" + ) + for row in rows: + ( + listing_id, + url, + price, + condominium, + iptu, + area, + bedrooms, + suites, + parking, + raw_path, + photo_count, + ) = row + description, details, address = parse_html(raw_path) + values = ( + listing_id, + price, + condominium, + iptu, + area, + bedrooms, + suites, + parking, + photo_count, + address, + details, + description, + url, + ) + print("\t".join("" if value is None else clean(str(value)) for value in values)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/reports/cruzeiro_top10/parameters.json b/reports/cruzeiro_top10/parameters.json new file mode 100644 index 0000000..3c49b3d --- /dev/null +++ b/reports/cruzeiro_top10/parameters.json @@ -0,0 +1,22 @@ +{ + "analysis_date": "2026-07-17", + "listing_data_reference_date": "2026-07-17", + "database_path": "dfimoveis_data/dfimoveis.sqlite3", + "database_main_file_sha256": "7a5102a62bc66a344bd9440fa36301d10a57711bcdd263b767b65e88f70957f4", + "logical_snapshot_sha256": "fe604aab8af7f67c833e6de2a0fc81c7dc2950ca440ad7b23ebd03d0c2546188", + "database_mode": "read_only_transaction_with_wal", + "search_url_fragment": "/cruzeiro/novo/", + "listing_limit": 100, + "observed_listing_count": 65, + "database_crawl_run_count": 16, + "active_listing_count": 65, + "new_listing_count_since_previous_report": 0, + "material_price_change_count": 1, + "ranking_unit": "probable_physical_property", + "numeric_score_used": false, + "photo_first_pass_limit_per_listing": 16, + "photo_finalist_count": 17, + "photo_files_reviewed_distinct": 1289, + "first_photo_sale_marker_audit_count": 10, + "visually_unavailable_listing_ids": [] +} diff --git a/reports/cruzeiro_top10/photo_similarity.py b/reports/cruzeiro_top10/photo_similarity.py new file mode 100644 index 0000000..a48e4fe --- /dev/null +++ b/reports/cruzeiro_top10/photo_similarity.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Suggest duplicate Cruzeiro ads from visually similar local photos. + +Uses a simple difference hash as a screening signal. Results are candidates for +manual review, never automatic proof that two ads describe the same property. +""" + +from __future__ import annotations + +import sqlite3 +from collections import defaultdict +from pathlib import Path + +from PIL import Image, UnidentifiedImageError + + +ROOT = Path(__file__).resolve().parents[2] +DB_PATH = ROOT / "dfimoveis_data" / "dfimoveis.sqlite3" +DATA_ROOT = DB_PATH.parent + + +def difference_hash(path: Path) -> int | None: + try: + with Image.open(path) as image: + pixels = list(image.convert("L").resize((9, 8)).getdata()) + except (OSError, UnidentifiedImageError): + return None + value = 0 + for row in range(8): + offset = row * 9 + for column in range(8): + value = (value << 1) | (pixels[offset + column] > pixels[offset + column + 1]) + return value + + +def main() -> None: + connection = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) + connection.execute("PRAGMA query_only = ON") + connection.execute("BEGIN") + rows = connection.execute( + """ + WITH cruzeiro AS ( + SELECT s.listing_id + FROM listing_searches AS s + JOIN listings AS l USING (listing_id) + WHERE instr(search_url, '/cruzeiro/novo/') > 0 + AND l.inactive_at IS NULL + ), uncommon AS ( + SELECT sha256 + FROM photos + WHERE sha256 IS NOT NULL + GROUP BY sha256 + HAVING COUNT(DISTINCT listing_id) <= 5 + ) + SELECT p.listing_id, p.local_path + FROM photos AS p + JOIN cruzeiro AS c USING (listing_id) + JOIN uncommon AS u USING (sha256) + WHERE p.local_path IS NOT NULL + ORDER BY p.listing_id, p.ordinal + """ + ).fetchall() + connection.close() + + hashes: dict[str, list[int]] = defaultdict(list) + for listing_id, relative_path in rows: + value = difference_hash(DATA_ROOT / relative_path) + if value is not None: + hashes[listing_id].append(value) + + ids = sorted(hashes) + print("listing_a\tlisting_b\tvisually_similar_photos") + for index, first_id in enumerate(ids): + for second_id in ids[index + 1 :]: + available = list(hashes[second_id]) + matches = 0 + for first_hash in hashes[first_id]: + distances = [(first_hash ^ second_hash).bit_count() for second_hash in available] + if distances and min(distances) <= 6: + available.pop(distances.index(min(distances))) + matches += 1 + if matches >= 3: + print(f"{first_id}\t{second_id}\t{matches}") + + +if __name__ == "__main__": + main() diff --git a/reports/cruzeiro_top10/query.sql b/reports/cruzeiro_top10/query.sql new file mode 100644 index 0000000..ae13f95 --- /dev/null +++ b/reports/cruzeiro_top10/query.sql @@ -0,0 +1,48 @@ +PRAGMA query_only = ON; + +-- Universo do Cruzeiro Novo. +SELECT + l.listing_id, + l.url, + l.price_brl, + l.condominium_brl, + l.iptu_brl, + l.area_m2, + l.bedrooms, + l.suites, + l.parking_spaces, + l.first_seen_at, + l.last_seen_at, + l.raw_html_path +FROM listings AS l +JOIN listing_searches AS s USING (listing_id) +WHERE instr(s.search_url, '/cruzeiro/novo/') > 0 + AND l.inactive_at IS NULL +ORDER BY l.price_brl, l.listing_id +LIMIT 100; + +-- Cardinalidade de fotos por anúncio. +SELECT p.listing_id, COUNT(*) AS photo_count +FROM photos AS p +JOIN listing_searches AS s USING (listing_id) +WHERE instr(s.search_url, '/cruzeiro/novo/') > 0 + AND EXISTS ( + SELECT 1 FROM listings AS l + WHERE l.listing_id = p.listing_id AND l.inactive_at IS NULL + ) +GROUP BY p.listing_id +ORDER BY p.listing_id +LIMIT 100; + +-- Número de estados históricos por anúncio. +SELECT h.listing_id, COUNT(*) AS snapshot_count +FROM listing_history AS h +JOIN listing_searches AS s USING (listing_id) +WHERE instr(s.search_url, '/cruzeiro/novo/') > 0 + AND EXISTS ( + SELECT 1 FROM listings AS l + WHERE l.listing_id = h.listing_id AND l.inactive_at IS NULL + ) +GROUP BY h.listing_id +ORDER BY h.listing_id +LIMIT 100; diff --git a/reports/cruzeiro_top10/results.csv b/reports/cruzeiro_top10/results.csv new file mode 100644 index 0000000..1182129 --- /dev/null +++ b/reports/cruzeiro_top10/results.csv @@ -0,0 +1,11 @@ +rank,representative_listing_id,probable_duplicate_ids,price_brl,area_m2,bedrooms,suites,floor,elevator,parking_spaces,decision +1,1345146,,670000,65,3,1,4,yes,,candidato forte +2,1232283,,600000,70,3,1,3,unknown,2,candidato forte +3,1357415,,630000,67.2,3,1,4,unknown,,candidato forte condicionado a escadas +4,1357633,,600000,64,3,0,2,unknown,,visitar +5,1376958,,599000,62,3,1,2,no,,visitar +6,1345903,,565000,64.5,3,1,1,unknown,0,visitar +7,1339324,,665000,64,3,1,3,unknown,,visitar após esclarecer acessibilidade +8,1370889,1375200,695000,62,3,1,2,unknown,,visitar uma única vez +9,1372268,,595000,62.61,3,0,1,unknown,2,visitar como alternativa de valor +10,1373131,,725000,65,3,0,2,no,,visitar se o prêmio pela reforma for aceitável diff --git a/reports/cruzeiro_vs_mangueiral/README.md b/reports/cruzeiro_vs_mangueiral/README.md new file mode 100644 index 0000000..175277a --- /dev/null +++ b/reports/cruzeiro_vs_mangueiral/README.md @@ -0,0 +1,133 @@ +# Ranking reconstruído — Cruzeiro Novo x Jardins Mangueiral + +**Data do snapshot:** 17/07/2026 +**Unidade:** imóvel físico provável +**Finalidade:** priorizar investigação e visitas; não estimar preço de transação nem substituir vistoria + +## Resumo executivo + +O Jardins Mangueiral continua oferecendo os melhores candidatos por espaço, suíte, vagas e preço anunciado. O Cruzeiro Novo continua superior em centralidade, acesso ao Plano Piloto e menor dependência de carro. + +A casa `1279892`, QC 10, permanece em primeiro lugar geral pelo equilíbrio entre R$ 595 mil, condição visual, três quartos prováveis e quintal convertido sem eliminação total da área aberta. O apartamento `1345146`, SHCES 1207, é o melhor do Cruzeiro e fica em segundo: reforma coerente, suíte e elevador anunciado, mas com preço maior e vaga ainda incerta. + +Confiança do ranking: **moderada para priorização de visitas e baixa para decisão de compra**. Fotos e anúncios não confirmam documentação, estrutura, sistemas, ruído, conforto térmico ou preço negociável. + +## Universo e atualização + +- Fonte: DF Imóveis. +- Banco: 159 anúncios, 295 snapshots e 6.272 mídias. +- Cruzeiro Novo: 65 anúncios acumulados e ativos. +- Jardins Mangueiral: 94 anúncios acumulados; 84 ativos no banco e 10 inativos. +- Cobertura útil: coletas de 13 e 17/07/2026. +- Histórico: ainda curto demais para tendência, liquidez ou tempo real de mercado. + +O banco foi aberto com `mode=ro`, `PRAGMA query_only = ON` e transação de leitura consistente para incluir o WAL. O `quick_check` retornou `ok`. + +## Método de reconstrução + +O comparativo foi refeito a partir dos rankings regionais corrigidos, preservando a ordem relativa dentro de cada região. Não foram usados preço por metro quadrado nem pontuação numérica, porque as áreas das casas e apartamentos não são semanticamente comparáveis e os pesos não foram aprovados. + +A ordem geral combina: + +1. funcionalidade de longo prazo e três quartos utilizáveis; +2. condição visual e risco de reforma; +3. iluminação, ventilação, circulação e armazenamento aparentes; +4. preço, custos conhecidos e margem para adequação; +5. acessibilidade, vagas e dependência de carro; +6. riscos específicos da tipologia; +7. qualidade e coerência da evidência fotográfica; +8. compatibilidade da localização com a rotina registrada no projeto. + +No Cruzeiro, os principais ajustes de risco são escadas, ausência de garagem, sistemas antigos e obras do bloco. No Mangueiral, são planta original, regularização das ampliações, impermeabilização, calor/ventilação e logística. + +## Auditoria de disponibilidade pelas fotos + +A primeira foto dos 20 finalistas foi inspecionada especificamente para “vendido”, “negócio fechado” ou sinal equivalente. + +- Nenhum dos dez finalistas do Cruzeiro apresentou esse sinal. +- No Mangueiral, quatro anúncios que apareceram em versões anteriores foram removidos e colocados em quarentena visual: `1346231`, `1349519`, `1351729` e `1366801`. +- O banco ainda os tratava como ativos; para um ranking de visitas, a evidência visual prevaleceu como sinal de indisponibilidade operacional. +- A marca não comprova registro, preço ou conclusão jurídica da transação. + +## Ranking geral + +| # | Região | Anúncio | Local | Preço | Motivo principal | Pendência decisiva | +|---:|---|---|---|---:|---|---| +| 1 | Mangueiral | [1279892](https://www.dfimoveis.com.br/imovel/casa-condominio-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-10-rua-f-1279892) | QC 10 | R$ 595.000 | Melhor equilíbrio entre preço, estado e espaço externo | Planta, áreas divergentes e regularização | +| 2 | Cruzeiro | [1345146](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-1207-bloco-a-1345146) | SHCES 1207 A | R$ 670.000 | Melhor alternativa urbana; reformado e com elevador anunciado | Vaga, área da matrícula e sistemas | +| 3 | Mangueiral | [1350367](https://www.dfimoveis.com.br/imovel/casa-condominio-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-6-rua-c-1350367) | QC 6 | R$ 569.000 | Preço competitivo e ampliação social aparentemente funcional | Área, licenças, calor e drenagem | +| 4 | Cruzeiro | [1232283](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-309-bloco-b-1232283) | SHCES 309 B | R$ 600.000 | 70 m², suíte, nascente/vazado e duas vagas anunciadas | Elevador e natureza jurídica das vagas | +| 5 | Mangueiral | [1346017](https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-10-1346017) | QC 10 | R$ 577.000 | Clara, pouco personalizada e com quintal majoritariamente aberto | Taxas, orientação e regularização da cobertura | +| 6 | Mangueiral | [1397378](https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-8-rua-b-1397378) | QC 8 | R$ 600.000 | QC prática e implantação compatível com a planta de 68 m² | Manutenção, drenagem e galeria contaminada | +| 7 | Cruzeiro | [1357415](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-1109-1357415) | SHCES 1109 | R$ 630.000 | Boa reforma, suíte e posição nascente/vazada | Confirmar elevador antes de agendar | +| 8 | Mangueiral | [1359960](https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-7-1359960) | QC 7 | R$ 610.000 | Ampliação social flexível sem segunda cozinha completa evidente | Calor, ventilação e impermeabilização | +| 9 | Cruzeiro | [1357633](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-309-1357633) | SHCES 309 | R$ 600.000 | Reforma uniforme e sistemas declarados renovados | Elevador, garagem, condomínio e prova da reforma | +| 10 | Cruzeiro | [1376958](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-911-bloco-b-1376958) | SHCES 911 B | R$ 599.000 | Reformado, nascente, vazado e com suíte | Sem elevador e garagem não informada | +| 11 | Mangueiral | [1319996](https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-14-1319996) | QC 14 | R$ 649.000 | Reforma completa, marcenaria e placas solares | QC, cobertura, taxas e homologação solar | +| 12 | Cruzeiro | [1345903](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-1303-1345903) | SHCES 1303 | R$ 565.000 | Menor preço competitivo e primeiro andar | Sem garagem; ruído e segurança do térreo | +| 13 | Mangueiral | [1324014](https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-12-rua-k-1324014) | QC 12 | R$ 649.000 | Acabamento interno e marcenaria fortes | Vaga, redundância gourmet e ventilação | +| 14 | Cruzeiro | [1339324](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-703-1339324) | SHCES 703 | R$ 665.000 | Integração social, suíte e boa apresentação visual | Terceiro andar, elevador e garagem desconhecidos | +| 15 | Mangueiral | [1333569](https://www.dfimoveis.com.br/imovel/casa-condominio-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-6-1333569) | QC 6 | R$ 650.000 | Ampliação social em uso e QC alternativa prática | Conceito dos 90 m² e manutenção da cobertura | +| 16 | Cruzeiro | [1370889](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-207-1370889) / [1375200](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-207-1375200) | SHCES 207 | R$ 695.000 | Apartamento reformado com suíte | Duplicata, andar, elevador, vaga e área | +| 17 | Mangueiral | [1359480](https://www.dfimoveis.com.br/imovel/casa-condominio-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-4-1359480) | QC 4 | R$ 535.000 | Referência de menor preço habitável | QC, planta, documentação e acabamento externo | +| 18 | Cruzeiro | [1372268](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-305-bloco-i-1372268) | SHCES 305 I | R$ 595.000 | Primeiro andar e duas vagas anunciadas | Provar vagas e orçar modernização | +| 19 | Mangueiral | [1356197](https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-4-1356197) | QC 4 | R$ 649.000 | Acabamento interno forte e casa vazia | Espaço coberto parece garagem; regularização | +| 20 | Cruzeiro | [1373131](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-305-1373131) | SHCES 305 | R$ 725.000 | Reforma tecnicamente bem descrita | Sem elevador, sem garagem e prêmio de preço | + +## Comparação regional + +| Dimensão | Cruzeiro Novo | Jardins Mangueiral | +|---|---|---| +| Faixa do top 10 | R$ 565 mil–R$ 725 mil | R$ 535 mil–R$ 650 mil | +| Mediana do top 10 | R$ 615 mil | R$ 610 mil | +| Espaço | 62–70 m²; conceito de área mais homogêneo | 68–90 m² informados; ampliações pouco comparáveis | +| Vagas | Frequentemente ausentes ou incertas | Uma ou duas na maioria dos finalistas | +| Acessibilidade | Depende fortemente de andar e elevador | Escada interna permanente em todos os sobrados padrão | +| Reforma | Elétrica, hidráulica e prédio antigos | Cobertura, drenagem, impermeabilização e ampliação | +| Mobilidade | Centralidade e menor dependência de carro | Maior dependência logística; proximidade de São Sebastião | +| Espaço social | Compacto | Quintal e ampliações mais flexíveis | +| Documentação crítica | Vaga, área, obras do bloco | Planta original e regularização das ampliações | + +## Sensibilidade à prioridade pessoal + +| Prioridade dominante | Região favorecida | Primeiro candidato | +|---|---|---| +| Centralidade e acesso ao Plano | Cruzeiro | 1345146 | +| Espaço, suíte, vagas e custo-benefício | Mangueiral | 1279892 | +| Menor preço habitável | Mangueiral | 1359480, com concessões | +| Apartamento próximo de R$ 600 mil | Cruzeiro | 1232283 | +| Menor risco de ampliação irregular | Cruzeiro | 1345146 ou 1357633 | +| Menor risco de sistemas prediais antigos | Mangueiral | 1346017 ou 1397378, sujeito a vistoria | +| Proximidade de São Sebastião e rede familiar | Mangueiral | 1279892 | + +## Imóveis imediatamente fora do corte + +- **Cruzeiro:** [1382466](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-101-bloco-c-1382466), [1290510](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-911-bloco-a-1290510) e [1371813](https://www.dfimoveis.com.br/imovel/apartamento-3-quartos-venda-novo-cruzeiro-df-shces-quadra-1109-bloco-d-1371813). +- **Mangueiral:** [1368562](https://www.dfimoveis.com.br/imovel/casa-condominio-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-5-1368562). + +## Próxima ação recomendada + +As quatro visitas mais informativas continuam sendo: + +1. `1279892`, para testar a melhor hipótese de casa equilibrada; +2. `1345146`, para testar a melhor hipótese urbana com elevador; +3. `1350367`, para testar custo-benefício e ampliação no Mangueiral; +4. `1232283`, para testar apartamento maior, suíte e vagas anunciadas. + +Registrar após cada visita: tempo real de deslocamento, sensação de espaço, ruído, estacionamento, escadas, ventilação, manutenção esperada e vontade de permanecer por 10–20 anos. Essas quatro visitas devem definir qual região recebe a rodada seguinte. + +## Limitações + +- Preços são pedidos, não transações. +- A cobertura histórica é curta e de um único portal. +- A situação financeira do projeto precisa ser confirmada antes de proposta ou preço máximo. +- Fotos não validam estrutura, sistemas, ruído, conforto térmico ou documentação. +- A planta original das casas e vagas/elevadores dos apartamentos continuam pendentes. +- A escolha regional ainda depende de uma preferência não consolidada: centralidade versus casa, espaço e proximidade familiar. + +## Reprodutibilidade + +- `results.csv`: ranking reconstruído. +- `parameters.json`: universo, método e fingerprint lógico do snapshot. +- `reports/cruzeiro_top10/`: fontes do ranking regional do Cruzeiro. +- `reports/mangueiral_top10/`: fontes do ranking regional do Mangueiral. diff --git a/reports/cruzeiro_vs_mangueiral/parameters.json b/reports/cruzeiro_vs_mangueiral/parameters.json new file mode 100644 index 0000000..c9bc432 --- /dev/null +++ b/reports/cruzeiro_vs_mangueiral/parameters.json @@ -0,0 +1,31 @@ +{ + "analysis_date": "2026-07-17", + "listing_data_reference_date": "2026-07-17", + "database_path": "dfimoveis_data/dfimoveis.sqlite3", + "database_main_file_sha256": "046e0a2fef3eb3c2e5ebdf07d30bd04a31db0bb1ea315ccc536c6bf8ba96577e", + "logical_snapshot_sha256": "fe604aab8af7f67c833e6de2a0fc81c7dc2950ca440ad7b23ebd03d0c2546188", + "database_mode": "read_only_transaction_with_wal", + "source_reports": [ + "reports/cruzeiro_top10/README.md", + "reports/mangueiral_top10/README.md" + ], + "cruzeiro_listing_count": 65, + "mangueiral_listing_count": 94, + "mangueiral_active_listing_count": 84, + "mangueiral_inactive_listing_count": 10, + "total_listing_count": 159, + "database_snapshot_count": 295, + "database_photo_count": 6272, + "database_crawl_run_count": 16, + "cruzeiro_photo_files_reviewed_distinct": 1289, + "mangueiral_photo_files_reviewed_distinct_cumulative": 1907, + "total_photo_files_reviewed_distinct_cumulative": 3196, + "regional_finalists_compared": 20, + "ranking_merge_method": "preserve_regional_order_then_interleave_by_cross_typology_visit_priority", + "report_rebuilt_from_regional_results": true, + "ranking_unit": "probable_physical_property", + "numeric_score_used": false, + "cross_typology_price_per_m2_used": false, + "first_photo_sale_marker_audit_count": 20, + "visually_unavailable_listing_ids": ["1346231", "1349519", "1351729", "1366801"] +} diff --git a/reports/cruzeiro_vs_mangueiral/results.csv b/reports/cruzeiro_vs_mangueiral/results.csv new file mode 100644 index 0000000..123fa30 --- /dev/null +++ b/reports/cruzeiro_vs_mangueiral/results.csv @@ -0,0 +1,21 @@ +rank,region,representative_listing_id,probable_duplicate_ids,location,price_brl,area_m2,bedrooms,suites,parking_spaces,key_condition,decision +1,mangueiral,1279892,,QC 10,595000,68,3,1,1,planta original e ampliações pendentes de documentos,candidato forte +2,cruzeiro,1345146,,SHCES 1207 bloco A,670000,65,3,1,,elevador anunciado e vaga pendente,candidato forte +3,mangueiral,1350367,,QC 6,569000,68,3,1,2,área ampliação e conforto térmico pendentes,candidato forte +4,cruzeiro,1232283,,SHCES 309 bloco B,600000,70,3,1,2,elevador e natureza das vagas pendentes,candidato forte condicionado +5,mangueiral,1346017,,QC 10,577000,68,3,1,2,taxas orientação e cobertura pendentes,candidato forte +6,mangueiral,1397378,,QC 8,600000,68,3,1,2,planta manutenção e cobertura pendentes,candidato forte +7,cruzeiro,1357415,,SHCES 1109,630000,67.2,3,1,,quarto andar e elevador desconhecido,confirmar elevador antes da visita +8,mangueiral,1359960,,QC 7,610000,68,3,1,2,conforto ambiental do quintal fechado pendente,visitar +9,cruzeiro,1357633,,SHCES 309,600000,64,3,0,,elevador garagem e condomínio pendentes,visitar +10,cruzeiro,1376958,,SHCES 911 bloco B,599000,62,3,1,,sem elevador e garagem não informada,visitar +11,mangueiral,1319996,,QC 14,649000,68,3,1,2,localização cobertura e placas solares pendentes,visitar +12,cruzeiro,1345903,,SHCES 1303,565000,64.5,3,1,0,primeiro andar e somente estacionamento público,alternativa de valor +13,mangueiral,1324014,,QC 12,649000,68,3,1,,vaga conforto da área gourmet e regularização pendentes,visitar +14,cruzeiro,1339324,,SHCES 703,665000,64,3,1,,terceiro andar elevador e garagem desconhecidos,esclarecer acessibilidade +15,mangueiral,1333569,,QC 6,650000,90,3,1,2,conceito de área e cobertura pendentes,visitar após os anteriores +16,cruzeiro,1370889,1375200,SHCES 207,695000,62,3,1,,duplicata provável elevador e garagem desconhecidos,confirmar anúncio primário +17,mangueiral,1359480,,QC 4,535000,70,3,1,2,planta documentação e acabamento externo pendentes,referência de valor +18,cruzeiro,1372268,,SHCES 305 bloco I,595000,62.61,3,0,2,vagas e custo de modernização pendentes,alternativa de valor +19,mangueiral,1356197,,QC 4,649000,68,3,1,2,uso do espaço coberto e regularização pendentes,visitar após os anteriores +20,cruzeiro,1373131,,SHCES 305,725000,65,3,0,,sem elevador e garagem não informada,visitar apenas se reforma justificar prêmio diff --git a/reports/database_assessment.md b/reports/database_assessment.md new file mode 100644 index 0000000..4039556 --- /dev/null +++ b/reports/database_assessment.md @@ -0,0 +1,303 @@ +# Avaliação inicial do banco de dados + +**Data da avaliação:** 13 de julho de 2026 +**Banco:** `dfimoveis_data/dfimoveis.sqlite3` +**Escopo:** diagnóstico estrutural e de qualidade anterior a qualquer análise dos imóveis + +## 1. Pergunta respondida + +O banco já representa de forma segura anúncios, snapshots históricos e imóveis físicos, com cobertura e qualidade suficientes para iniciar análises imobiliárias? + +**Resposta curta:** ele representa anúncios e estados de conteúdo, mas ainda não representa imóveis físicos deduplicados. A coleta cobre apenas um dia e contém exatamente um snapshot por anúncio. Preços, áreas e quartos podem ser usados somente após validações específicas; localização e dados de anunciante estão fortemente contaminados e não devem ser usados como estão. Portanto, o banco ainda não sustenta tendência histórica, tempo de mercado, preço por m² ou comparáveis por localização. + +## 2. Segurança e método + +A análise foi feita exclusivamente com consultas agregadas ou amostras pequenas. O banco foi aberto com `sqlite3 -readonly` ou URI `mode=ro&immutable=1`, sempre com `PRAGMA query_only = ON`. Nenhum scraper foi executado e nenhuma instrução SQL de escrita foi enviada ao banco. + +Identificação do arquivo analisado: + +- tamanho: 5.017.600 bytes; +- modificação: `2026-07-13 14:20:34.601285975 -03:00`; +- SHA-256 inicial: `1ce3128b488c50c993d7328ac6e24b5905f083a7e04e3f636407eea1edd02b9d`; +- sem arquivos `-wal` ou `-shm` e sem processo com o arquivo aberto na inspeção inicial; +- `PRAGMA quick_check`: `ok`; +- `PRAGMA foreign_key_check`: nenhuma violação. + +Na validação final, o arquivo principal continuava com o mesmo tamanho, `mtime` e SHA-256. Como seu modo persistido é WAL, o cliente SQLite havia criado dois sidecars de runtime: `dfimoveis.sqlite3-shm`, com 32.768 bytes, e `dfimoveis.sqlite3-wal`, vazio. Eles não foram removidos, conforme a regra explícita do projeto. + +O esquema completo e as decisões de interpretação estão em `docs/ACTUAL_SCHEMA.md`. + +## 3. Universo, período e cobertura + +Não houve filtro ou exclusão de registros para este diagnóstico. O universo físico contém: + +| Unidade | Quantidade | +|---|---:| +| Execuções do coletor | 4 | +| Anúncios únicos por `listing_id` | 156 | +| URLs de anúncio únicas | 156 | +| Snapshots de conteúdo | 156 | +| Vínculos anúncio–busca | 156 | +| Fotos registradas | 5.386 | +| Anúncios com pelo menos uma foto | 156 | + +### Cobertura temporal + +Todas as datas técnicas válidas estão em ISO 8601. A coleta cobre somente **13/07/2026**: + +| Evento | UTC | Horário de Brasília (UTC−3) | +|---|---|---| +| Primeira execução iniciada | 12:20:58 | 09:20:58 | +| Primeira observação de anúncio | 12:56:32 | 09:56:32 | +| Última observação de anúncio | 13:25:51 | 10:25:51 | +| Última execução concluída | 17:20:34 | 14:20:34 | + +As duas primeiras execuções terminaram com `status = ok`, mas registraram zero páginas, URLs e detalhes. As duas execuções com coleta efetiva somaram: + +- 9 páginas de busca; +- 156 URLs encontradas; +- 156 detalhes processados com sucesso e zero falhas; +- 5.383 fotos baixadas e 3 falhas; +- zero anúncios marcados como inativos. + +As buscas cobertas foram: + +1. `https://www.dfimoveis.com.br/venda/df/cruzeiro/novo/apartamento/3-quartos` — 65 anúncios; +2. `https://www.dfimoveis.com.br/venda/df/brasilia/jardins-mangueiral/casa/3,4-quartos` — 91 anúncios. + +O universo é, portanto, intencionalmente limitado a um portal e a duas consultas. A busca do Jardins Mangueiral aceita três **ou quatro** quartos: 89 casas foram extraídas com três quartos anunciados e 2 com quatro. Mesmo os 89 registros de três quartos não comprovam a planta original exigida pelo projeto. + +## 4. Como as entidades são representadas + +### Anúncio + +`listings` guarda uma linha por `listing_id` do DF Imóveis, com URL única e o estado corrente. Os 156 IDs, URLs e hashes correntes são distintos. Essa é uma identidade de publicação no portal, não de unidade residencial. + +### Snapshot ou observação histórica + +`listing_history` guarda uma linha por par único `(listing_id, content_hash)`. Isso é um histórico de **mudanças de conteúdo**: uma nova observação idêntica não cria novo snapshot. `listings.first_seen_at` e `last_seen_at` condensam a janela observada. + +No snapshot atual: + +- todos os 156 anúncios têm exatamente um registro histórico; +- o hash histórico é igual ao hash corrente em todos os casos; +- `captured_at = first_seen_at` em todos os casos; +- `first_seen_at = last_seen_at` em todos os casos. + +Assim, não há série histórica real ainda. Não é possível medir mudança de preço, republicação, duração, saída ou retorno do estoque. + +### Execução do scraper + +`crawl_runs` registra horários, estado e contadores JSON, mas não existe `run_id` em `listings`, `listing_history` ou `listing_searches`. A execução geradora só pode ser inferida por horário. Isso limita auditoria, cobertura por execução e diagnóstico de ausências. + +### Imóvel físico + +Não existe tabela ou chave para imóvel físico, unidade, endereço normalizado ou relação de duplicidade. Anúncios de corretores diferentes permanecem independentes. A base também não contém hashes perceptuais; possui apenas SHA-256 exato das imagens. + +Logo, as unidades válidas hoje são: + +- anúncio único: suportado por `listing_id`; +- estado alterado do anúncio: suportado por `listing_history`; +- imóvel físico provável/confirmado: **não suportado sem derivação e revisão**. + +## 5. Qualidade dos dados + +### 5.1 Integridade estrutural + +Pontos positivos: + +- arquivo íntegro segundo `quick_check`; +- nenhuma FK órfã em histórico, buscas ou fotos; +- todos os JSONs são sintaticamente válidos; +- todos os IDs de anúncio têm 6–7 caracteres numéricos; +- todas as URLs pertencem ao domínio e padrão esperados; +- todos os 156 HTMLs brutos referenciados existem; +- todos os hashes de conteúdo têm 64 caracteres; +- não há URL, hash corrente ou caminho de HTML repetido. + +Limitações estruturais: + +- nenhuma relação entre execução e item; +- fonte implícita, sem `source_id`; +- área armazenada em um único conceito; +- mídia ligada ao anúncio, não ao snapshot; +- ausência de entidade de imóvel físico e de localização normalizada; +- apenas autoíndices; não há índices por datas ou atributos analíticos. + +### 5.2 Completude dos principais campos + +| Campo | Ausentes | Percentual | +|---|---:|---:| +| preço | 0 | 0,0% | +| área | 0 | 0,0% | +| quartos | 0 | 0,0% | +| condomínio | 59 | 37,8% | +| IPTU | 144 | 92,3% | +| suítes | 41 | 26,3% | +| vagas | 57 | 36,5% | +| cidade | 156 | 100,0% | +| UF | 156 | 100,0% | +| nome do anunciante | 156 | 100,0% | +| latitude/longitude | 156 / 156 | 100,0% / 100,0% | +| data de publicação | 156 | 100,0% | +| data de inativação | 156 | 100,0% | + +Esses percentuais medem apenas nulos. Para campos textuais, a taxa de preenchimento é enganosa porque o parser capturou conteúdo da página em vez do valor pretendido. + +### 5.3 Contaminação de texto + +Os problemas mais graves são: + +- 146 endereços (93,6%) têm mais de 300 caracteres; +- 154 bairros (98,7%) têm mais de 100 caracteres; +- os dois valores curtos de bairro são `.` e um trecho de descrição, também inválidos; +- 98 códigos de anunciante (62,8%) têm mais de 100 caracteres; +- 153 valores de CRECI (98,1%) têm mais de 100 caracteres. + +Os textos longos incluem navegação, formulários, política de privacidade e outros fragmentos da página. Eles podem conter dados de contato exibidos pelo portal; por isso, não foram reproduzidos neste relatório. Esses campos devem ser considerados **indisponíveis**, não apenas ruidosos. + +Os títulos têm somente quatro valores distintos e funcionam como títulos genéricos por tipologia. As 156 descrições são distintas, mas têm apenas 156–161 caracteres, compatíveis com resumos/metadados truncados, não com a descrição completa. `extra_json` contém um array `json_ld` vazio nos 156 registros e não resolve a reextração. + +### 5.4 Valores estruturados e outliers + +| Tipo anunciado | N | Preço mínimo | Preço máximo | Área mínima | Área máxima | Quartos | +|---|---:|---:|---:|---:|---:|---:| +| Apartamento | 65 | R$ 480.000 | R$ 2.030.598 | 61 m² | 113 m² | 3 | +| Casa | 91 | R$ 450.000 | R$ 570.000.000 | 0,11 m² | 131,46 m² | 3–4 | + +Não há valores nulos ou não positivos em preço e área, mas isso não significa validade: + +- anúncio `1351663`: R$ 570.000.000, forte indício de erro de escala ou parsing; +- anúncio `1370918`: 0,11 m², área fisicamente implausível. + +Não foi aplicado corte nem correção silenciosa. Também não se calculou preço por m², pois `area_m2` não distingue área útil, privativa, construída, total ou terreno. + +### 5.5 Fotos e arquivos brutos + +Das 5.386 linhas em `photos`: + +- 5.383 têm arquivo local, e o tamanho em disco coincide com `bytes`; +- 3 não têm hash, MIME, tamanho ou caminho, correspondendo às três falhas registradas pelo coletor; +- há 4.514 WebP, 558 JPEG, 311 PNG e 3 sem MIME; +- os anúncios possuem de 5 a 61 fotos; +- não há caminho local nem ordinal duplicado dentro de anúncio. + +Há 3.307 hashes distintos. Cento e quatorze hashes aparecem em mais de um anúncio, e um deles aparece nos 156 anúncios. Isso revela logos, selos, placeholders ou outros ativos comuns. Hash idêntico pode ser sinal de anúncio duplicado somente depois de excluir ativos genéricos e combinar outros sinais. + +## 6. Duplicidades e imóveis físicos prováveis + +Não há duplicidade técnica imediata por `listing_id`, URL ou `content_hash`. Uma assinatura exploratória composta por bairro, tipo, preço, área, quartos e vagas encontrou 13 grupos, equivalentes a 18 linhas excedentes. Esse resultado **não é evidência de 18 imóveis duplicados**, porque: + +- o bairro está contaminado; +- preço, área e quartos podem coincidir entre unidades distintas; +- fotos compartilhadas incluem ativos genéricos; +- não há endereço/unidade confiável nem telefone normalizado; +- SHA-256 só detecta arquivos idênticos, não versões redimensionadas da mesma foto. + +Consequentemente, o método de deduplicação desta avaliação foi apenas técnico: unicidade de anúncio por `listing_id`. Nenhuma fusão de anúncios nem contagem de imóveis físicos foi produzida. + +Uma futura camada derivada deve combinar, com confiança e revisão manual: + +- endereço/QC/quadra/bloco/unidade reextraídos; +- imagens após remoção de ativos comuns, idealmente com hash perceptual; +- preço, área, planta, quartos e vagas validados; +- anunciante e descrição normalizados; +- estados `unreviewed`, `probable`, `confirmed`, `rejected` e `ambiguous`. + +## 7. Possíveis quebras de coleta + +1. **Localização:** `address` e `neighborhood` capturaram grandes trechos da página; cidade, UF e coordenadas ficaram vazias. +2. **Anunciante:** `advertiser_code` e `creci` frequentemente capturaram texto extenso; `advertiser_name` ficou vazio. +3. **Dados estruturados:** o `json_ld` foi preservado como array vazio em todos os anúncios. +4. **Escala numérica:** pelo menos um preço e uma área são claramente suspeitos. +5. **Fotos:** três falhas foram registradas de modo consistente, sem arquivo local. +6. **Execuções vazias:** duas execuções `ok` não coletaram páginas. Sem motivo/configuração persistidos, não é possível diferenciar teste, filtro vazio ou falha silenciosa. +7. **Filtro do Mangueiral:** a URL coletada inclui casas de quatro quartos e não identifica planta original. + +## 8. Limitações para qualquer conclusão imobiliária + +- Um único dia não permite análise histórica ou tendência. +- A amostra cobre apenas DF Imóveis e duas buscas específicas. +- Preço anunciado não é preço negociado. +- Não há transações, status comprovado de venda ou evidência de fechamento. +- Não há imóveis físicos deduplicados. +- Não há localização analítica confiável por QC, quadra ou bloco. +- Não há conceito comparável de área. +- O estado de reforma, planta original, elevador, garagem, andar e qualidade funcional não está modelado de forma suficiente. +- Para Jardins Mangueiral, `bedrooms = 3` não valida planta original de três quartos. + +Portanto, ainda não é apropriado produzir medianas regionais, comparáveis, preço por m², tempo de anúncio, liquidez ou recomendações de visita com base apenas neste banco. + +## 9. Próximas ações recomendadas + +Antes da análise dos imóveis: + +1. corrigir e testar a extração de endereço, bairro, anunciante e CRECI usando os HTMLs já salvos, sem recrawling; +2. criar uma derivação reproduzível fora do banco original e preservar os valores brutos; +3. separar áreas privativa, útil, construída, total e de terreno, deixando nulo o conceito não comprovado; +4. criar regras explícitas de validação para preço e área, mantendo os outliers identificados em quarentena; +5. filtrar o Mangueiral por planta original de três quartos, não apenas pelo número anunciado; +6. coletar múltiplas datas e auditar a cobertura por busca antes de estimar permanência, entradas ou saídas; +7. numa evolução aprovada do coletor, adicionar `source_id`, `run_id` nos snapshots, referência ao HTML bruto histórico e versão/configuração da coleta; +8. construir `property_candidates` apenas em uma camada derivada, com confiança e revisão, sem fundir registros no banco original. + +## 10. Consultas de reprodução + +Todas devem ser executadas com `sqlite3 -readonly dfimoveis_data/dfimoveis.sqlite3` ou conexão Python URI em `mode=ro` e `PRAGMA query_only = ON`. + +```sql +-- Objetos do esquema +SELECT type, name, tbl_name, sql +FROM sqlite_master +ORDER BY type, name; + +-- Contagens (executar uma tabela por vez após validar os nomes) +SELECT COUNT(*) FROM crawl_runs; +SELECT COUNT(*) FROM listings; +SELECT COUNT(*) FROM listing_history; +SELECT COUNT(*) FROM listing_searches; +SELECT COUNT(*) FROM photos; + +-- Cobertura +SELECT MIN(started_at), MAX(finished_at), COUNT(*) +FROM crawl_runs; + +SELECT MIN(first_seen_at), MAX(last_seen_at), + COUNT(DISTINCT date(first_seen_at)) +FROM listings; + +-- Número de snapshots por anúncio +SELECT snapshots, COUNT(*) AS listings +FROM ( + SELECT listing_id, COUNT(*) AS snapshots + FROM listing_history + GROUP BY listing_id +) +GROUP BY snapshots; + +-- Completude de campos críticos +SELECT + COUNT(*) AS total, + SUM(price_brl IS NULL) AS missing_price, + SUM(area_m2 IS NULL) AS missing_area, + SUM(condominium_brl IS NULL) AS missing_condominium, + SUM(iptu_brl IS NULL) AS missing_iptu, + SUM(latitude IS NULL OR longitude IS NULL) AS missing_coordinates +FROM listings; + +-- Contaminação textual sem imprimir os textos +SELECT + SUM(length(address) > 300) AS long_address, + SUM(length(neighborhood) > 100) AS long_neighborhood, + SUM(length(advertiser_code) > 100) AS long_advertiser_code, + SUM(length(creci) > 100) AS long_creci +FROM listings; + +-- Integridade referencial e física +PRAGMA quick_check; +PRAGMA foreign_key_check; +``` + +## 11. Implicação para a decisão de compra + +Esta avaliação reduz o risco de transformar uma coleta tecnicamente bem-sucedida em falsa precisão de mercado. O próximo ganho real não virá de comparar preços imediatamente, mas de corrigir localização e semântica de área, acumular histórico e construir uma deduplicação reversível. Até lá, os registros são úteis como inventário bruto de anúncios e arquivos, não como avaliação consolidada de imóveis físicos. diff --git a/reports/mangueiral_top10/README.md b/reports/mangueiral_top10/README.md new file mode 100644 index 0000000..06bd464 --- /dev/null +++ b/reports/mangueiral_top10/README.md @@ -0,0 +1,184 @@ +# Ranking preliminar — casas no Jardins Mangueiral + +**Data de referência do snapshot:** 17/07/2026 +**Relatório revisado:** 17/07/2026 +**Unidade principal:** imóvel físico provável, não snapshot nem anúncio isolado +**Finalidade:** priorizar visitas; não estimar preço negociado nem substituir vistoria ou validação documental + +## Pergunta + +Quais são as dez casas do Jardins Mangueiral que mais merecem atenção segundo as preferências e regras do projeto, dando às fotografias importância comparável à dos dados estruturados? + +## Universo e método + +Foram avaliados os 94 anúncios acumulados encontrados pela busca de casas no Jardins Mangueiral: 84 estavam ativos e 10 inativos no snapshot consistente de 17/07/2026. A base cobre um único portal. Há 159 anúncios nas duas regiões, 295 snapshots de conteúdo e 6.272 mídias; 133 anúncios têm dois snapshots, mas quatro mudanças foram materialmente relevantes nos campos imobiliários e apenas uma delas foi preço. + +Como o banco estava em WAL, ele foi aberto com `mode=ro`, sem `immutable=1`, dentro de uma transação de leitura consistente e com `PRAGMA query_only = ON`. Entre os anúncios ativos, 80 informam três quartos e preço plausível entre R$ 450 mil e R$ 1,15 milhão. O anúncio de R$ 1,15 milhão descreve outra implantação, no condomínio American Garden, e não a casa-padrão de 68 m² das QCs; não é comparável ao universo prioritário. Esses números descrevem anúncios, não imóveis únicos nem preços negociados. + +Não foi calculado preço por metro quadrado: os campos e descrições misturam área privativa, construída, total e de terreno. Tampouco foi criada pontuação numérica, porque os pesos não foram aprovados. A ordem é uma **prioridade de visita ajustada por risco**, considerando em conjunto: + +1. compatibilidade provável com a planta original de três quartos; +2. funcionalidade, circulação, armazenamento e flexibilidade de longo prazo; +3. qualidade real da conversão do quintal, sem penalizar fechamento automaticamente; +4. luz e ventilação aparentes, conservação e coerência dos acabamentos; +5. risco e transtorno de adequação; +6. preço, condomínio, IPTU, vagas e área anunciados; +7. QCs citadas nos relatos de moradores, apenas como evidência anedótica; +8. completude, coerência e confiabilidade das fotos. + +### Filtro da planta original + +Os dez imóveis abaixo têm fachada, posição de escada, distribuição e dormitórios fotografados compatíveis com a tipologia padrão de três quartos, e nenhum deles é descrito como conversão de dois para três quartos. Isso sustenta apenas a classificação **planta original provável**. A confirmação exige planta, matrícula ou documentação da unidade. O anúncio 1374012, por exemplo, foi retirado da disputa apesar do preço baixo porque a própria descrição informa “3 quartos sendo 1 adaptado”. + +### Revisão fotográfica + +Em 17/07/2026, a primeira foto de todos os escolhidos foi auditada especificamente para marcas de “vendido” ou “negócio fechado”. Quatro anúncios que chegaram a integrar versões do ranking foram removidos por esse critério: `1346231`, `1349519`, `1351729` e `1366801`. + +A revisão anterior havia examinado 1.892 arquivos distintos. Nesta atualização foram revisadas as imagens iniciais dos novos anúncios elegíveis e, em detalhe, 15 imagens coerentes do novo finalista `1397378`; imagens posteriores da galeria que pertencem visualmente a outros imóveis foram descartadas como contaminação. As plantas oficiais de duas e três unidades de três quartos e os mapas locais em `resources/` também foram confrontados com a galeria e a QC. + +As fotos foram usadas para avaliar coerência da planta, presença dos três dormitórios, circulação, luz, ventilação aparente, conservação, cozinha, banheiros, lavanderia, quintal, integração social e qualidade das ampliações. Elas não validam elétrica, hidráulica, impermeabilização, estrutura, calor, ruído ou regularização. + +As galerias baixadas frequentemente continuam, depois das imagens do imóvel, com recomendações do portal, fotos de outras casas, selos de “vendido”, publicidade e logos. Esses arquivos foram vistos, identificados pela quebra de coerência visual e desconsiderados. Por isso, quantidade bruta de fotos não foi tratada como qualidade de evidência, e hashes compartilhados foram usados apenas para triagem manual de duplicidade. + +## Ranking + +### 1. Anúncio 1279892 — QC 10 + +- **Anunciado:** R$ 595.000; 68 m² no campo estruturado e 107 m² na descrição; condomínio R$ 383; IPTU R$ 1.050; 3 quartos, 1 suíte; 1 vaga informada no banco. +- **Por que lidera:** oferece o melhor equilíbrio entre preço, localização considerada boa nos relatos, reforma pronta e planta visualmente coerente. A diferença para casas mais caras parece estar mais na ausência de mobiliário do que em obra obrigatória. +- **Fotos:** ambientes claros, sala ampla, cozinha planejada bem executada, banheiros atualizados e três dormitórios reconhecíveis. O fundo combina área coberta e trechos abertos, preservando luz e ventilação; não aparece uma segunda cozinha completa redundante. +- **Pendências:** esclarecer os conceitos de 68 e 107 m², a DCE citada, regularização das ampliações, número jurídico de vagas e impermeabilização da cobertura translúcida. +- **Ação:** candidato forte; visitar primeiro. +- **URL:** https://www.dfimoveis.com.br/imovel/casa-condominio-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-10-rua-f-1279892 + +### 2. Anúncio 1350367 — QC 6 + +- **Anunciado:** R$ 569.000; 68 m² no banco, 107 m² totais e terreno de 131 m² na descrição; condomínio R$ 320; 3 quartos, 1 suíte; 2 vagas. +- **Por que está no topo:** preço baixo para o grupo final, QC considerada alternativa prática e ampliação social que parece efetivamente utilizável. +- **Fotos:** casa vazia, clara e aparentemente reformada; banheiros e pisos coerentes; três dormitórios aparentes. O quintal foi convertido em uma varanda ampla com churrasqueira e bancada, mas mantém abertura lateral e circulação, em vez de virar cômodo escuro ou segunda cozinha fechada. +- **Pendências:** confirmar área construída, licenças, ventilação e calor sob a cobertura, drenagem, elétrica da ampliação e o que falta de marcenaria/mobiliário. +- **Ação:** candidato forte; visitar. +- **URL:** https://www.dfimoveis.com.br/imovel/casa-condominio-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-6-rua-c-1350367 + +### 3. Anúncio 1346017 — QC 10 + +- **Anunciado:** R$ 577.000; 68 m²; condomínio e IPTU ausentes; 3 quartos, 1 suíte; 2 vagas. +- **Por que se destaca:** casa pronta, luminosa e pouco personalizada por preço competitivo, em QC considerada boa nos relatos. +- **Fotos:** sala e jantar funcionais, cozinha planejada, três quartos claros e banheiros conservados. O quintal permanece majoritariamente aberto, com cobertura leve e uso social simples; é uma das soluções menos arriscadas para ventilação e manutenção. +- **Pendências:** confirmar proteção contra chuva, privacidade, área de serviço, taxas, orientação solar e regularização de qualquer cobertura. +- **Ação:** candidato forte; visitar. +- **URL:** https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-10-1346017 + +### 4. Anúncio 1397378 — QC 8 + +- **Anunciado:** R$ 600.000; 68 m²; condomínio R$ 316,14; IPTU R$ 1.119,67; 3 quartos, 1 suíte; 2 vagas. +- **Por que se destaca:** QC 8 tem acesso prático a comércio e serviços nos mapas fornecidos, e a casa combina preço intermediário com implantação próxima da planta original de três quartos. +- **Fotos:** sala e cozinha claras, marcenaria utilizável, três dormitórios aparentes e escada/andar superior compatíveis com a planta de 68 m². O fundo mantém trecho aberto, corredor lateral e cobertura parcial, sem transformar todo o quintal em segunda cozinha. Há sinais de acabamento simples e manutenção pontual, mas não de reforma integral imediata. +- **Pendências:** confirmar planta e documentos, origem das marcas/acabamentos, drenagem linear, impermeabilização da cobertura, orientação solar e itens de marcenaria que permanecem. Parte final da galeria está contaminada por fotos de outros imóveis e foi desconsiderada. +- **Ação:** candidato forte; visitar depois dos três primeiros. +- **URL:** https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-8-rua-b-1397378 + +### 5. Anúncio 1359960 — QC 7 + +- **Anunciado:** R$ 610.000; 68 m²; condomínio R$ 339; IPTU R$ 1.128,19; 3 quartos, 1 suíte; 2 vagas. +- **Por que entra:** QC considerada alternativa prática e conversão do quintal que cria uma sala social ampla, em vez de apenas instalar equipamentos com rótulo “gourmet”. +- **Fotos:** boa sala frontal, cozinha planejada, três quartos aparentes e grande espaço coberto nos fundos, hoje usado para descanso e convivência. Não foi identificada segunda cozinha completa; a ampliação parece flexível e mobiliável. +- **Pendências:** o fechamento é extenso; testar calor, ventilação cruzada, iluminação noturna, exaustão, acústica e impermeabilização antes de valorizá-lo. +- **Ação:** visitar com foco no conforto ambiental da ampliação. +- **URL:** https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-7-1359960 + +### 6. Anúncio 1319996 — QC 14 + +- **Anunciado:** R$ 649.000; 68 m²; condomínio e IPTU ausentes; 3 quartos, 1 suíte; 2 vagas. +- **Por que se destaca:** uma das reformas mais completas e coerentes, com placas solares e boa separação entre convivência e lavanderia. +- **Fotos:** acabamento uniforme, marcenaria abundante, três dormitórios aparentes, banheiros atualizados e área externa coberta com bancada, churrasqueira, abertura lateral e circulação suficiente para receber pessoas. +- **Pendências:** QC 14 não está entre as QCs priorizadas pelos relatos; confirmar taxas, homologação das placas, ventilação/exaustão, manutenção da cobertura e escopo técnico da reforma. +- **Ação:** visitar como referência de imóvel pronto. +- **URL:** https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-14-1319996 + +### 7. Anúncio 1324014 — QC 12 + +- **Anunciado:** R$ 649.000; 68 m²; condomínio e IPTU ausentes; 3 quartos, 1 suíte; vaga não extraída. +- **Por que entra:** acabamento interno e marcenaria fortes, com área social coberta bem executada e condição aparente de ocupação sem reforma relevante. +- **Fotos:** cozinha, sala e banheiros atualizados; a área gourmet é ampla e iluminada, mas concentra churrasqueira, bancada e apoio muito próximos da cozinha principal. +- **Pendências:** confirmar vaga, planta original, ventilação, exaustão, conforto térmico, drenagem, regularização da cobertura e se a duplicação de equipamentos melhora de fato a rotina. +- **Ação:** visitar depois dos candidatos mais baratos e das QCs prioritárias. +- **URL:** https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-12-rua-k-1324014 + +### 8. Anúncio 1333569 — QC 6 + +- **Anunciado:** R$ 650.000; 90 m²; condomínio e IPTU ausentes; 3 quartos, 1 suíte; 2 vagas. +- **Por que entra:** QC considerada alternativa prática e ampliação social já em uso, com abertura superior e lateral que pode mitigar o fechamento do quintal. +- **Fotos:** casa ocupada, bem conservada e funcional; a área externa reúne jantar, estar e churrasqueira, com cobertura aparentemente translúcida/retrátil. Os cômodos íntimos parecem utilizáveis, embora menos bem documentados que nos primeiros colocados. +- **Pendências:** confirmar se os 90 m² são comparáveis, funcionamento e manutenção da cobertura, exaustão, calor, iluminação, taxas e regularização. +- **Ação:** visitar depois dos oito primeiros. +- **URL:** https://www.dfimoveis.com.br/imovel/casa-condominio-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-6-1333569 + +### 9. Anúncio 1359480 — QC 4 + +- **Anunciado:** R$ 535.000; 70 m²; condomínio R$ 330; IPTU ausente; 3 quartos, 1 suíte; 2 vagas. +- **Por que entra:** é a alternativa de menor preço entre os finalistas habitáveis e preserva margem financeira para custos de aquisição e adequações. +- **Fotos:** sala integrada utilizável, cozinha e banheiros funcionais e três dormitórios aparentes. O quintal/lavanderia têm acabamento mais simples que os líderes, mas não sugerem obra estrutural imediata nas imagens. +- **Pendências:** QC 4 precisa provar conveniência logística; confirmar planta original, área de 70 m², documentação, drenagem, impermeabilização e orçamento de acabamento externo. +- **Ação:** visitar como referência de valor e limite inferior de preço. +- **URL:** https://www.dfimoveis.com.br/imovel/casa-condominio-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-4-1359480 + +### 10. Anúncio 1356197 — QC 4 + +- **Anunciado:** R$ 649.000; 68 m²; condomínio R$ 316; IPTU ausente; 3 quartos, 1 suíte; 2 vagas. +- **Por que entra:** casa vazia com acabamento interno forte e condição aparente de ocupação sem reforma relevante. +- **Fotos:** sala, cozinha, banheiros e marcenaria bem conservados; boa parte do espaço coberto parece funcionar como garagem, com menor evidência de quintal social útil. +- **Pendências:** confirmar planta original, uso e ventilação do espaço coberto, drenagem, impermeabilização, regularização e conveniência da QC 4. +- **Ação:** visitar depois dos nove primeiros. +- **URL:** https://www.dfimoveis.com.br/imovel/casa-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-4-1356197 + +## Quase entraram + +- **[1368562](https://www.dfimoveis.com.br/imovel/casa-condominio-3-quartos-venda-jardins-mangueiral-brasilia-df-qc-5-1368562), QC 5, R$ 720.000:** ampliação de aproximadamente 110 m² bem executada e ventilada por cobertura translúcida, mas o prêmio de preço e a QC menos prioritária reduzem a urgência da visita. + +## Quarentenas e exclusões relevantes + +- **1351729:** a primeira foto declara “VENDIDO” e “negócio fechado”. Isso não comprova registro ou preço da transação, mas é evidência suficiente de indisponibilidade operacional; removido do ranking de visitas por correção do usuário em 17/07/2026. +- **1349519:** a primeira foto também declara “VENDIDO” e “negócio fechado”, com o código correspondente ao anúncio. Removido do ranking de visitas por correção do usuário em 17/07/2026. +- **1366801:** a primeira foto declara “VENDIDO” e “negócio fechado”, com o código 1468 correspondente ao anúncio. Removido do ranking de visitas por correção do usuário em 17/07/2026. +- **1346231:** a primeira foto declara “VENDIDO” e “negócio fechado”, com o código 1428 correspondente ao anúncio. Removido após auditoria sistemática das primeiras fotos em 17/07/2026. +- **1350829 e 1358463:** anunciados com quatro quartos; fora do filtro. +- **1374012:** a descrição declara que um dos três quartos é adaptado; não há prova suficiente de planta original de três quartos. +- **1351663:** preço estruturado de R$ 570 milhões, erro evidente de escala; excluído até correção. +- **1369473 e 1336887:** galerias dominadas por material genérico ou de outros anúncios, sem evidência suficiente do imóvel. +- **1301400 e 1201070:** fotos com marcação de vendido/permuta; tratadas como inconsistência, não prova de venda. +- **1041635 e 1383513:** mesmo imóvel provável pelas fotos; a repetição não foi contada como duas opções. +- **1239730, 1242337, 1294027, 1359192, 1359662 e 1367341:** grupo de republicações prováveis da mesma unidade; a oferta não foi inflada. +- **1354016 e 1377683:** duplicata provável após revisão visual; seria uma única opção, não duas. +- **1356153, 1358854 e 1358920:** grupo provável de uma mesma casa vazia. +- **1348449 e 1351461:** descrições e composição praticamente idênticas na QC 13; tratar como mesmo imóvel até confirmação. + +## Qualidade e limitações + +- O banco contém anúncios e snapshots de conteúdo, não imóveis físicos confirmados. Os dois pontos observados ainda são insuficientes para tendência ou tempo real de mercado. +- Entre os 84 anúncios ativos do Mangueiral, condomínio falta em 40 e IPTU em 74; não foi possível comparar custo mensal com consistência. +- Área existe em todos os registros, mas não é semanticamente comparável. Os próprios finalistas exibem divergências entre banco e descrição. +- A busca e as galerias contêm material contaminante do portal. Duplicidades foram classificadas manualmente e permanecem probabilísticas. +- Anúncio, foto e descrição não confirmam planta original, regularização da ampliação, documentação, estrutura ou sistemas. +- As preferências por QC vêm de relatos anedóticos e precisam ser testadas em deslocamentos e visitas reais. +- A referência financeira de R$ 650 mil a R$ 750 mil não foi assumida como atual. O ranking prioriza visitas, não define capacidade de compra ou preço máximo. + +## Verificações obrigatórias nas visitas + +1. solicitar planta original, matrícula, habite-se e documentos das ampliações; +2. medir os três quartos e conferir posição da escada, fachada e implantação; +3. testar calor, ventilação, luz e ruído com portas e janelas fechadas e abertas; +4. inspecionar telhado, rufos, calhas, impermeabilização, drenagem e sinais de infiltração; +5. testar quadro elétrico, aterramento, tomadas, hidráulica, pressão e escoamento; +6. verificar se a área gourmet duplica a cozinha ou realmente melhora a rotina; +7. confirmar condomínio, IPTU, vagas, taxas extras, regras de obra e atas; +8. percorrer a QC em horários relevantes para comércio, trânsito, segurança, ruído e estacionamento; +9. só depois estimar adequação e definir preço máximo de negociação. + +## Reprodutibilidade + +- `analysis.py`: extrai banco e HTMLs em leitura, produzindo TSV. +- `photo_similarity.py`: sinaliza pares por hash visual simples; exige revisão humana e sofre com imagens genéricas do portal. +- `query.sql`: consultas principais de universo e qualidade. +- `parameters.json`: parâmetros e fingerprint do snapshot. +- `results.csv`: ranking em formato tabular. diff --git a/reports/mangueiral_top10/__pycache__/analysis.cpython-312.pyc b/reports/mangueiral_top10/__pycache__/analysis.cpython-312.pyc new file mode 100644 index 0000000..db9baaa Binary files /dev/null and b/reports/mangueiral_top10/__pycache__/analysis.cpython-312.pyc differ diff --git a/reports/mangueiral_top10/__pycache__/analysis.cpython-314.pyc b/reports/mangueiral_top10/__pycache__/analysis.cpython-314.pyc new file mode 100644 index 0000000..272ea52 Binary files /dev/null and b/reports/mangueiral_top10/__pycache__/analysis.cpython-314.pyc differ diff --git a/reports/mangueiral_top10/__pycache__/photo_similarity.cpython-312.pyc b/reports/mangueiral_top10/__pycache__/photo_similarity.cpython-312.pyc new file mode 100644 index 0000000..254cd15 Binary files /dev/null and b/reports/mangueiral_top10/__pycache__/photo_similarity.cpython-312.pyc differ diff --git a/reports/mangueiral_top10/__pycache__/photo_similarity.cpython-314.pyc b/reports/mangueiral_top10/__pycache__/photo_similarity.cpython-314.pyc new file mode 100644 index 0000000..4d8e4b1 Binary files /dev/null and b/reports/mangueiral_top10/__pycache__/photo_similarity.cpython-314.pyc differ diff --git a/reports/mangueiral_top10/analysis.py b/reports/mangueiral_top10/analysis.py new file mode 100644 index 0000000..72547a1 --- /dev/null +++ b/reports/mangueiral_top10/analysis.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +"""Read-only extraction helper for the Jardins Mangueiral ranking.""" + +from __future__ import annotations + +import argparse +import gzip +import re +import sqlite3 +import sys +from pathlib import Path + +from bs4 import BeautifulSoup + + +ROOT = Path(__file__).resolve().parents[2] +DB_PATH = ROOT / "dfimoveis_data" / "dfimoveis.sqlite3" +DATA_ROOT = DB_PATH.parent +SEARCH_FRAGMENT = "/jardins-mangueiral/" + + +def clean(value: str | None) -> str: + return re.sub(r"\s+", " ", value or "").strip() + + +def sanitize_description(value: str) -> str: + value = re.sub(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b", "[email]", value) + value = re.sub( + r"(?:\(\d{2}\)|\b\d{2}\b)\s*(?:\d[\s.-]*){5,11}\d", + "[telefone]", + value, + ) + value = re.sub(r"\bCRECI\b.{0,30}", "", value, flags=re.IGNORECASE) + return clean(value) + + +def parse_html(relative_path: str) -> tuple[str, str, str]: + with gzip.open(DATA_ROOT / relative_path, "rt", encoding="utf-8", errors="replace") as fh: + soup = BeautifulSoup(fh.read(), "lxml") + + description_node = soup.select_one("div.assined-imv") + description = "" + if description_node: + for node in description_node.select("span, a, button"): + node.decompose() + description = sanitize_description(description_node.get_text(" ", strip=True)) + + details: list[str] = [] + for item in soup.select("ul.details-text li"): + text = clean(item.get_text(" ", strip=True)) + if text and text not in details: + details.append(text) + + address_node = soup.select_one('[itemprop="address"]') + address = clean(address_node.get_text(" ", strip=True) if address_node else "") + return description, " | ".join(details), address + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--ids-only", action="store_true") + args = parser.parse_args() + + connection = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) + connection.execute("PRAGMA query_only = ON") + connection.execute("BEGIN") + rows = connection.execute( + """ + SELECT + l.listing_id, l.url, l.price_brl, l.condominium_brl, l.iptu_brl, + l.area_m2, l.bedrooms, l.suites, l.parking_spaces, + l.raw_html_path, COUNT(p.source_url) AS photo_count + FROM listings AS l + JOIN listing_searches AS s USING (listing_id) + LEFT JOIN photos AS p USING (listing_id) + WHERE instr(s.search_url, ?) > 0 + AND l.inactive_at IS NULL + GROUP BY l.listing_id + ORDER BY l.price_brl, l.listing_id + LIMIT 150 + """, + (SEARCH_FRAGMENT,), + ).fetchall() + connection.close() + + if args.ids_only: + for row in rows: + print(row[0]) + return 0 + + print( + "listing_id\tprice_brl\tcondominium_brl\tiptu_brl\tarea_m2\tbedrooms\t" + "suites\tparking_spaces\tphoto_count\taddress\tdetails\tdescription\turl" + ) + for row in rows: + ( + listing_id, + url, + price, + condominium, + iptu, + area, + bedrooms, + suites, + parking, + raw_path, + photo_count, + ) = row + description, details, address = parse_html(raw_path) + values = ( + listing_id, + price, + condominium, + iptu, + area, + bedrooms, + suites, + parking, + photo_count, + address, + details, + description, + url, + ) + print("\t".join("" if value is None else clean(str(value)) for value in values)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/reports/mangueiral_top10/parameters.json b/reports/mangueiral_top10/parameters.json new file mode 100644 index 0000000..c3e9eb8 --- /dev/null +++ b/reports/mangueiral_top10/parameters.json @@ -0,0 +1,29 @@ +{ + "analysis_date": "2026-07-17", + "listing_data_reference_date": "2026-07-17", + "database_path": "dfimoveis_data/dfimoveis.sqlite3", + "database_main_file_sha256": "7a5102a62bc66a344bd9440fa36301d10a57711bcdd263b767b65e88f70957f4", + "logical_snapshot_sha256": "fe604aab8af7f67c833e6de2a0fc81c7dc2950ca440ad7b23ebd03d0c2546188", + "database_mode": "read_only_transaction_with_wal", + "search_url_fragment": "/jardins-mangueiral/", + "listing_limit": 150, + "observed_listing_count": 94, + "active_listing_count": 84, + "inactive_listing_count": 10, + "database_crawl_run_count": 16, + "new_listing_count_since_previous_report": 3, + "active_plausible_price_three_bedroom_count": 80, + "ranking_unit": "probable_physical_property", + "numeric_score_used": false, + "original_three_bedroom_requires_document_confirmation": true, + "photo_first_pass_limit_per_listing": 16, + "photo_first_pass_files": 1449, + "photo_finalist_count": 19, + "photo_finalist_files": 747, + "photo_files_reviewed_distinct_cumulative": 1907, + "new_finalist_photos_reviewed": 15, + "plans_reviewed": 2, + "map_captures_reviewed": 6, + "first_photo_sale_marker_audit_count": 10, + "visually_unavailable_listing_ids": ["1346231", "1349519", "1351729", "1366801"] +} diff --git a/reports/mangueiral_top10/photo_similarity.py b/reports/mangueiral_top10/photo_similarity.py new file mode 100644 index 0000000..57cc216 --- /dev/null +++ b/reports/mangueiral_top10/photo_similarity.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Suggest duplicate Mangueiral ads from visually similar local photos. + +Uses a simple difference hash as a screening signal. Results are candidates for +manual review, never automatic proof that two ads describe the same property. +""" + +from __future__ import annotations + +import sqlite3 +from collections import defaultdict +from pathlib import Path + +from PIL import Image, UnidentifiedImageError + + +ROOT = Path(__file__).resolve().parents[2] +DB_PATH = ROOT / "dfimoveis_data" / "dfimoveis.sqlite3" +DATA_ROOT = DB_PATH.parent + + +def difference_hash(path: Path) -> int | None: + try: + with Image.open(path) as image: + pixels = list(image.convert("L").resize((9, 8)).getdata()) + except (OSError, UnidentifiedImageError): + return None + value = 0 + for row in range(8): + offset = row * 9 + for column in range(8): + value = (value << 1) | (pixels[offset + column] > pixels[offset + column + 1]) + return value + + +def main() -> None: + connection = sqlite3.connect(f"file:{DB_PATH}?mode=ro", uri=True) + connection.execute("PRAGMA query_only = ON") + connection.execute("BEGIN") + rows = connection.execute( + """ + WITH mangueiral AS ( + SELECT s.listing_id + FROM listing_searches AS s + JOIN listings AS l USING (listing_id) + WHERE instr(search_url, '/jardins-mangueiral/') > 0 + AND l.inactive_at IS NULL + ), uncommon AS ( + SELECT sha256 + FROM photos + WHERE sha256 IS NOT NULL + GROUP BY sha256 + HAVING COUNT(DISTINCT listing_id) <= 5 + ) + SELECT p.listing_id, p.local_path + FROM photos AS p + JOIN mangueiral AS m USING (listing_id) + JOIN uncommon AS u USING (sha256) + WHERE p.local_path IS NOT NULL + ORDER BY p.listing_id, p.ordinal + """ + ).fetchall() + connection.close() + + hashes: dict[str, list[int]] = defaultdict(list) + for listing_id, relative_path in rows: + value = difference_hash(DATA_ROOT / relative_path) + if value is not None: + hashes[listing_id].append(value) + + ids = sorted(hashes) + print("listing_a\tlisting_b\tvisually_similar_photos") + for index, first_id in enumerate(ids): + for second_id in ids[index + 1 :]: + available = list(hashes[second_id]) + matches = 0 + for first_hash in hashes[first_id]: + distances = [(first_hash ^ second_hash).bit_count() for second_hash in available] + if distances and min(distances) <= 6: + available.pop(distances.index(min(distances))) + matches += 1 + if matches >= 3: + print(f"{first_id}\t{second_id}\t{matches}") + + +if __name__ == "__main__": + main() diff --git a/reports/mangueiral_top10/query.sql b/reports/mangueiral_top10/query.sql new file mode 100644 index 0000000..1113b86 --- /dev/null +++ b/reports/mangueiral_top10/query.sql @@ -0,0 +1,65 @@ +PRAGMA query_only = ON; + +-- Universo capturado pela busca do Jardins Mangueiral. +SELECT + l.listing_id, + l.url, + l.price_brl, + l.condominium_brl, + l.iptu_brl, + l.area_m2, + l.bedrooms, + l.suites, + l.parking_spaces, + l.first_seen_at, + l.last_seen_at, + l.raw_html_path +FROM listings AS l +JOIN listing_searches AS s USING (listing_id) +WHERE instr(s.search_url, '/jardins-mangueiral/') > 0 + AND l.inactive_at IS NULL +ORDER BY l.price_brl, l.listing_id +LIMIT 150; + +-- Cardinalidade de fotos por anúncio. A contagem inclui material genérico do portal. +SELECT p.listing_id, COUNT(*) AS photo_count +FROM photos AS p +JOIN listing_searches AS s USING (listing_id) +WHERE instr(s.search_url, '/jardins-mangueiral/') > 0 + AND EXISTS ( + SELECT 1 FROM listings AS l + WHERE l.listing_id = p.listing_id AND l.inactive_at IS NULL + ) +GROUP BY p.listing_id +ORDER BY p.listing_id +LIMIT 150; + +-- Número de snapshots por anúncio e cobertura temporal. +SELECT + h.listing_id, + COUNT(*) AS snapshot_count, + MIN(h.captured_at) AS first_capture, + MAX(h.captured_at) AS last_capture +FROM listing_history AS h +JOIN listing_searches AS s USING (listing_id) +WHERE instr(s.search_url, '/jardins-mangueiral/') > 0 + AND EXISTS ( + SELECT 1 FROM listings AS l + WHERE l.listing_id = h.listing_id AND l.inactive_at IS NULL + ) +GROUP BY h.listing_id +ORDER BY h.listing_id +LIMIT 150; + +-- Campos ausentes no universo. +SELECT + COUNT(*) AS listing_count, + SUM(l.condominium_brl IS NULL) AS missing_condominium, + SUM(l.iptu_brl IS NULL) AS missing_iptu, + SUM(l.area_m2 IS NULL) AS missing_area, + SUM(l.parking_spaces IS NULL) AS missing_parking +FROM listings AS l +JOIN listing_searches AS s USING (listing_id) +WHERE instr(s.search_url, '/jardins-mangueiral/') > 0 + AND l.inactive_at IS NULL +LIMIT 1; diff --git a/reports/mangueiral_top10/results.csv b/reports/mangueiral_top10/results.csv new file mode 100644 index 0000000..14bfe67 --- /dev/null +++ b/reports/mangueiral_top10/results.csv @@ -0,0 +1,11 @@ +rank,representative_listing_id,probable_duplicate_ids,qc,price_brl,area_m2,bedrooms,suites,parking_spaces,original_three_bedroom_status,decision +1,1279892,,10,595000,68,3,1,1,probable_pending_documents,strong_candidate +2,1350367,,6,569000,68,3,1,2,probable_pending_documents,strong_candidate +3,1346017,,10,577000,68,3,1,2,probable_pending_documents,strong_candidate +4,1397378,,8,600000,68,3,1,2,probable_pending_documents,strong_candidate +5,1359960,,7,610000,68,3,1,2,probable_pending_documents,visit_and_test_closed_yard +6,1319996,,14,649000,68,3,1,2,probable_pending_documents,visit +7,1324014,,12,649000,68,3,1,,probable_pending_documents,visit +8,1333569,,6,650000,90,3,1,2,probable_pending_documents,visit_after_top_eight +9,1359480,,4,535000,70,3,1,2,probable_pending_documents,visit_as_value_reference +10,1356197,,4,649000,68,3,1,2,probable_pending_documents,visit_after_top_nine diff --git a/requirements-dfimoveis.txt b/requirements-dfimoveis.txt new file mode 100644 index 0000000..5495687 --- /dev/null +++ b/requirements-dfimoveis.txt @@ -0,0 +1,9 @@ +httpx[http2]>=0.27,<1 +beautifulsoup4>=4.12,<5 +lxml>=5,<7 +# Used by the report photo-similarity screening scripts. +Pillow>=10,<13 +# Default browser automation (uses the locally installed Microsoft Edge): +playwright>=1.49,<2 +cloudscraper>=1.2,<2 +patchright>=1.0,<2 diff --git a/resources/maps/00aecaae-3e6b-4605-958f-70dbdb2b24b8.png b/resources/maps/00aecaae-3e6b-4605-958f-70dbdb2b24b8.png new file mode 100644 index 0000000..ff1e763 Binary files /dev/null and b/resources/maps/00aecaae-3e6b-4605-958f-70dbdb2b24b8.png differ diff --git a/resources/maps/1d424088-057c-43e8-9459-306978d0a456.png b/resources/maps/1d424088-057c-43e8-9459-306978d0a456.png new file mode 100644 index 0000000..3238036 Binary files /dev/null and b/resources/maps/1d424088-057c-43e8-9459-306978d0a456.png differ diff --git a/resources/maps/643d59ab-9deb-48b8-8b8f-f3d444aab4a2.png b/resources/maps/643d59ab-9deb-48b8-8b8f-f3d444aab4a2.png new file mode 100644 index 0000000..d675f33 Binary files /dev/null and b/resources/maps/643d59ab-9deb-48b8-8b8f-f3d444aab4a2.png differ diff --git a/resources/maps/6f86274e-fb86-4d74-b526-fe8fc0f34e07.png b/resources/maps/6f86274e-fb86-4d74-b526-fe8fc0f34e07.png new file mode 100644 index 0000000..3533e8f Binary files /dev/null and b/resources/maps/6f86274e-fb86-4d74-b526-fe8fc0f34e07.png differ diff --git a/resources/maps/7c219f0a-933e-4643-9c50-c83081a75677.png b/resources/maps/7c219f0a-933e-4643-9c50-c83081a75677.png new file mode 100644 index 0000000..5c5f5d1 Binary files /dev/null and b/resources/maps/7c219f0a-933e-4643-9c50-c83081a75677.png differ diff --git a/resources/maps/photo_2026-07-16_17-17-11.jpg b/resources/maps/photo_2026-07-16_17-17-11.jpg new file mode 100644 index 0000000..749746f Binary files /dev/null and b/resources/maps/photo_2026-07-16_17-17-11.jpg differ diff --git a/resources/maps/photo_2026-07-16_17-17-37.jpg b/resources/maps/photo_2026-07-16_17-17-37.jpg new file mode 100644 index 0000000..5d39f5c Binary files /dev/null and b/resources/maps/photo_2026-07-16_17-17-37.jpg differ diff --git a/resources/maps/photo_2026-07-16_17-17-44.jpg b/resources/maps/photo_2026-07-16_17-17-44.jpg new file mode 100644 index 0000000..68d0cd1 Binary files /dev/null and b/resources/maps/photo_2026-07-16_17-17-44.jpg differ diff --git a/resources/maps/photo_2026-07-16_17-21-28.jpg b/resources/maps/photo_2026-07-16_17-21-28.jpg new file mode 100644 index 0000000..d7ff5e5 Binary files /dev/null and b/resources/maps/photo_2026-07-16_17-21-28.jpg differ diff --git a/resources/maps/photo_2026-07-16_17-21-32.jpg b/resources/maps/photo_2026-07-16_17-21-32.jpg new file mode 100644 index 0000000..a87b873 Binary files /dev/null and b/resources/maps/photo_2026-07-16_17-21-32.jpg differ diff --git a/resources/maps/photo_2026-07-16_17-21-35.jpg b/resources/maps/photo_2026-07-16_17-21-35.jpg new file mode 100644 index 0000000..4449632 Binary files /dev/null and b/resources/maps/photo_2026-07-16_17-21-35.jpg differ diff --git a/resources/plans/74920737-ED7F-4E08-A7C9-C8F3C92C598A.jpeg b/resources/plans/74920737-ED7F-4E08-A7C9-C8F3C92C598A.jpeg new file mode 100644 index 0000000..f463347 Binary files /dev/null and b/resources/plans/74920737-ED7F-4E08-A7C9-C8F3C92C598A.jpeg differ diff --git a/resources/plans/B65F8041-DA75-4982-A5B5-15BB2B8AB465.jpeg b/resources/plans/B65F8041-DA75-4982-A5B5-15BB2B8AB465.jpeg new file mode 100644 index 0000000..89bc7bd Binary files /dev/null and b/resources/plans/B65F8041-DA75-4982-A5B5-15BB2B8AB465.jpeg differ diff --git a/response.html b/response.html new file mode 100644 index 0000000..bf80be3 --- /dev/null +++ b/response.html @@ -0,0 +1 @@ +Just a moment...
\ No newline at end of file diff --git a/search_list.txt b/search_list.txt new file mode 100644 index 0000000..e005498 --- /dev/null +++ b/search_list.txt @@ -0,0 +1,3 @@ +# One complete DFImoveis search URL per line +https://www.dfimoveis.com.br/venda/df/brasilia/jardins-mangueiral/casa/3,4-quartos +https://www.dfimoveis.com.br/venda/df/cruzeiro/novo/apartamento/3-quartos \ No newline at end of file diff --git a/used_commands.ps1 b/used_commands.ps1 new file mode 100644 index 0000000..b8ca597 --- /dev/null +++ b/used_commands.ps1 @@ -0,0 +1,19 @@ +# change working directory +Set-Location E:\forge\python\house-quest + +# install dependencies +.\.venv-scraper\Scripts\python.exe -m pip install -r requirements-dfimoveis.txt + +# clean up browser profile +Remove-Item -Recurse -Force .\dfimoveis_data\browser-profile + +# run Chrome with remote debugging enabled +& "C:\Program Files\Google\Chrome\Application\chrome.exe"` + --remote-debugging-port=9222 ` + --user-data-dir="E:\forge\python\house-quest\dfimoveis_data\browser-profile" + +# scraper execution command +.\.venv-scraper\Scripts\python.exe .\dfimoveis_scraper.py ` + --search-file .\search_list.txt ` + --output .\dfimoveis_data ` + --attach-chrome