Skip to content

Commit df4942e

Browse files
committed
fix(jobs): normaliza campos nulos do scraper e conta vagas salvas de verdade
O jobspy devolve None nas colunas que nao preencheu — description vem sempre nula com linkedin_fetch_description desligado — e `record.get("campo", "")` nao protege disso, porque a chave existe com valor nulo. Como os campos de DailyJob sao NOT NULL, o primeiro registro assim derrubava o bulk_create do termo inteiro com IntegrityError, descartando todas as vagas daquele termo. Apareceu em producao assim que o scrape voltou a funcionar. Corrige tambem as metricas do fetch diario: - jobs_skipped era sempre 0: `bulk_create(ignore_conflicts=True)` devolve a lista que recebeu (o Postgres nao retorna as linhas inseridas nesse modo), entao toda vaga repetida contava como nova. Agora medimos pela diferenca de linhas do termo no dia. - novo terms_empty separa "dia sem vagas" de "scraper quebrado", ja que search() engole as falhas e devolve lista vazia - evento fetch_daily_jobs_no_results (nivel error) quando nenhum termo entrega vaga alguma
1 parent 3139116 commit df4942e

2 files changed

Lines changed: 116 additions & 10 deletions

File tree

apps/jobs/services.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,20 @@ def build_review_for_user(course, job_searcher: JobSearcher) -> list[dict[str, A
164164
return combined[:5]
165165

166166

167+
def _job_field(value: Any, max_length: int | None = None) -> str:
168+
"""Normaliza um campo textual vindo do scraper para persistência.
169+
170+
O jobspy entrega `None` nas colunas que não preencheu (NaN convertido) —
171+
`record.get("campo", "")` não protege disso, porque a chave existe com valor
172+
nulo. Como os campos de DailyJob são NOT NULL, um único registro assim
173+
derrubava o bulk_create do termo inteiro (`description` vem sempre nula
174+
quando `linkedin_fetch_description` está desligado). Truncar no limite da
175+
coluna evita perder o lote pelo mesmo motivo.
176+
"""
177+
text = str(value).strip() if value is not None else ""
178+
return text[:max_length] if max_length else text
179+
180+
167181
def fetch_and_save_daily_jobs(job_searcher) -> dict[str, int]:
168182
"""Scrapa todos os SearchTerms ativos e persiste em DailyJob (BOT-02).
169183
@@ -179,6 +193,7 @@ def fetch_and_save_daily_jobs(job_searcher) -> dict[str, int]:
179193
search_terms = list(SearchTerm.objects.filter(is_default=True).select_related("course"))
180194
stats: dict[str, int] = {
181195
"terms_processed": 0,
196+
"terms_empty": 0,
182197
"jobs_saved": 0,
183198
"jobs_skipped": 0,
184199
"errors": 0,
@@ -188,23 +203,35 @@ def fetch_and_save_daily_jobs(job_searcher) -> dict[str, int]:
188203
kwargs = term.to_search_kwargs()
189204
try:
190205
results = job_searcher.search(terms=[term.term], **kwargs)
206+
if not results:
207+
# `search()` engole falhas de scrape e devolve lista vazia, então
208+
# esta contagem é o que separa "dia sem vagas" de "scraper quebrado".
209+
stats["terms_empty"] += 1
191210
to_create = [
192211
DailyJob(
193212
search_term=term,
194213
fetched_date=today,
195-
title=r.get("title", ""),
196-
company=r.get("company", ""),
197-
location=r.get("location", ""),
198-
job_url=r.get("job_url") or r.get("job_url_direct", ""),
199-
description=r.get("description", ""),
200-
job_type=r.get("job_type", ""),
214+
title=_job_field(r.get("title"), 255),
215+
company=_job_field(r.get("company"), 255),
216+
location=_job_field(r.get("location"), 255),
217+
job_url=_job_field(r.get("job_url") or r.get("job_url_direct"), 1000),
218+
description=_job_field(r.get("description")),
219+
job_type=_job_field(r.get("job_type"), 50),
201220
)
202221
for r in results
203222
if r.get("job_url") or r.get("job_url_direct")
204223
]
205-
created = DailyJob.objects.bulk_create(to_create, ignore_conflicts=True)
206-
stats["jobs_saved"] += len(created)
207-
stats["jobs_skipped"] += len(to_create) - len(created)
224+
# `bulk_create(ignore_conflicts=True)` devolve a lista que recebeu —
225+
# o Postgres não retorna as linhas inseridas nesse modo, então contar
226+
# o retorno marcaria toda vaga repetida como nova. Medimos pela
227+
# diferença de linhas do termo no dia.
228+
saved_before = DailyJob.objects.filter(search_term=term, fetched_date=today).count()
229+
DailyJob.objects.bulk_create(to_create, ignore_conflicts=True)
230+
saved = (
231+
DailyJob.objects.filter(search_term=term, fetched_date=today).count() - saved_before
232+
)
233+
stats["jobs_saved"] += saved
234+
stats["jobs_skipped"] += len(to_create) - saved
208235
JobSearchLog.objects.create(
209236
user=None,
210237
search_term=term.term,
@@ -230,6 +257,10 @@ def fetch_and_save_daily_jobs(job_searcher) -> dict[str, int]:
230257
deleted_old=deleted,
231258
**stats,
232259
)
260+
if search_terms and not stats["jobs_saved"] and not stats["jobs_skipped"]:
261+
# Nenhum termo entregou vaga alguma: é falha sistêmica (scraper bloqueado,
262+
# rede, runner quebrado), não um dia fraco. Evento próprio para alarme.
263+
logger.error("fetch_daily_jobs_no_results", **stats)
233264
return stats
234265

235266

apps/jobs/tests/test_daily_job.py

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -215,7 +215,13 @@ def test_retorna_dict_com_chaves_esperadas(self):
215215
]
216216
)
217217
stats = fetch_and_save_daily_jobs(job_searcher=searcher)
218-
assert set(stats.keys()) == {"terms_processed", "jobs_saved", "jobs_skipped", "errors"}
218+
assert set(stats.keys()) == {
219+
"terms_processed",
220+
"terms_empty",
221+
"jobs_saved",
222+
"jobs_skipped",
223+
"errors",
224+
}
219225

220226
def test_erro_em_termo_incrementa_errors_e_continua(self):
221227
from apps.courses.models import SearchTerm
@@ -237,6 +243,75 @@ def side_effect(terms, **kwargs):
237243
assert stats["terms_processed"] == 2
238244
assert DailyJob.objects.count() == 1
239245

246+
def test_persiste_vaga_com_campos_nulos_do_scraper(self):
247+
from apps.jobs.models import DailyJob
248+
from apps.jobs.services import fetch_and_save_daily_jobs
249+
250+
# Regressão de produção: o jobspy devolve None nas colunas que não
251+
# preencheu (description vem sempre nula sem linkedin_fetch_description)
252+
# e os campos do modelo são NOT NULL — um registro assim derrubava o
253+
# bulk_create do termo inteiro com IntegrityError.
254+
searcher = self._make_searcher(
255+
[
256+
{
257+
"title": "Dev Python",
258+
"company": "Corp",
259+
"location": None,
260+
"description": None,
261+
"job_type": None,
262+
"job_url": "https://example.com/nulo",
263+
},
264+
]
265+
)
266+
267+
stats = fetch_and_save_daily_jobs(job_searcher=searcher)
268+
269+
job = DailyJob.objects.get(job_url="https://example.com/nulo")
270+
assert (job.description, job.location, job.job_type) == ("", "", "")
271+
assert stats["errors"] == 0
272+
assert stats["jobs_saved"] == 1
273+
274+
def test_trunca_campos_maiores_que_a_coluna(self):
275+
from apps.jobs.models import DailyJob
276+
from apps.jobs.services import fetch_and_save_daily_jobs
277+
278+
searcher = self._make_searcher(
279+
[
280+
{
281+
"title": "T" * 300,
282+
"company": "Corp",
283+
"job_url": "https://example.com/longo",
284+
},
285+
]
286+
)
287+
288+
fetch_and_save_daily_jobs(job_searcher=searcher)
289+
290+
assert len(DailyJob.objects.get(job_url="https://example.com/longo").title) == 255
291+
292+
def test_conta_termo_sem_resultado_em_terms_empty(self):
293+
from apps.jobs.services import fetch_and_save_daily_jobs
294+
295+
stats = fetch_and_save_daily_jobs(job_searcher=self._make_searcher([]))
296+
297+
assert stats["terms_empty"] == 1
298+
assert stats["jobs_saved"] == 0
299+
300+
def test_vaga_repetida_conta_como_skipped_e_nao_como_salva(self):
301+
from apps.jobs.services import fetch_and_save_daily_jobs
302+
303+
searcher = self._make_searcher(
304+
[
305+
{"title": "Dev", "company": "Corp", "job_url": "https://example.com/dup"},
306+
]
307+
)
308+
fetch_and_save_daily_jobs(job_searcher=searcher)
309+
310+
stats = fetch_and_save_daily_jobs(job_searcher=searcher)
311+
312+
assert stats["jobs_saved"] == 0
313+
assert stats["jobs_skipped"] == 1
314+
240315
def test_cria_job_search_log_com_user_none(self):
241316
from apps.jobs.models import JobSearchLog
242317
from apps.jobs.services import fetch_and_save_daily_jobs

0 commit comments

Comments
 (0)