Compare commits

...

2 Commits

Author SHA1 Message Date
0f7d22750f [fix] solution/backend: Teams 카드 필수 필드 보완
웹훅 요청의 contentUrl 및 스키마 선언 누락을 공식 형식에 맞춤. HTTP 202와 채널 게시 성공을 구분하도록 검증 한계 기록.

검증: Teams 요청 형식 회귀 테스트 1건 통과. 워크플로 실제 수신은 별도 확인 필요.
2026-09-15 15:55:59 +09:00
3f47d5ecd2 [feat] solution/backend: 서치콘솔 자동 제출·색인 상태 추적 추가
사이트 발행 성공과 Google 색인 관측은 별도 상태다. 외부 API 장애로 발행이 실패하거나 재시작 때 추적 정보가 사라지지 않도록 분리.

- Google 클라이언트·배치·DB·Teams 알림 모듈 분리
- 기존 스케줄러 연결, 재시도·중복 실행 방지와 선택 설정 추가
- ORM·초기 DDL·마이그레이션·운영 설정 문서 동시 갱신

검증: 관련 59건 통과, compose 설정·diff 검사 통과. 추가 회귀 23건 통과, 기존 발행 검수 실패 1건은 변경 전 코드에서도 재현. 운영 배포·Google/Teams 실호출 미실행.
2026-09-15 14:50:30 +09:00
22 changed files with 1388 additions and 2 deletions

View File

@ -79,6 +79,14 @@ PUBLIC_WEB_BASE_URL=http://localhost
# SITE_PUBLIC_HOST=web4ai.o2osolution.ai # SITE_PUBLIC_HOST=web4ai.o2osolution.ai
# 비우면 색인 통보를 건너뛴다(발행은 정상) # 비우면 색인 통보를 건너뛴다(발행은 정상)
INDEXNOW_KEY= INDEXNOW_KEY=
# Google Search Console — 최초 소유권/서비스 계정 권한 설정 후 켠다 (docs/SEARCH_CONSOLE.md).
GSC_ENABLED=0
GSC_PROPERTY_URL=
GSC_CREDENTIALS_FILE=
GSC_CREDENTIALS_HOST_FILE=
GSC_ALERT_DAYS=7
GSC_ALERT_WEBHOOK_URL=
# 비우면 로컬 발행만 한다 # 비우면 로컬 발행만 한다
AZURE_STORAGE_CONNECTION_STRING= AZURE_STORAGE_CONNECTION_STRING=
AZURE_STORAGE_CONTAINER= AZURE_STORAGE_CONTAINER=

View File

@ -0,0 +1,13 @@
# 선택 연동. 키 파일은 저장소 밖에 두고 기존 API 스케줄러에만 읽기 전용으로 전달한다.
# docker compose -f docker-compose.yml -f docker-compose.search-console.yml up -d solution-backend
services:
solution-backend:
environment:
GSC_CREDENTIALS_FILE: /run/secrets/search-console.json
volumes:
- type: bind
source: ${GSC_CREDENTIALS_HOST_FILE:?Search Console 키 파일 절대경로 필요}
target: /run/secrets/search-console.json
read_only: true
bind:
create_host_path: false

View File

@ -69,6 +69,10 @@ BUILD 잡 (worker) ─ services/build_service.py:99 run_build()
## 3. 서빙 — 테스트 서버가 정적 파일을 직접 서빙한다 ## 3. 서빙 — 테스트 서버가 정적 파일을 직접 서빙한다
Google 추적은 발행 잡 밖에서 실행한다. 기존 API 스케줄러가 발행 완료 DB를 감지해
사이트맵 제출·색인 조회·알림을 수행하고 `site_search_status`에 저장한다.
선택 설정/인증/재시도 경계는 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md)가 단일 출처다.
**결정 (2026-08-31).** 발행 사이트는 **서버 안에서 nginx 가 정적 파일로 서빙한다.** **결정 (2026-08-31).** 발행 사이트는 **서버 안에서 nginx 가 정적 파일로 서빙한다.**
Azure Blob 업로드 경로(`azure_static.py`)는 코드에 있고 동작하지만 **지금은 켜지 않는다** — Azure Blob 업로드 경로(`azure_static.py`)는 코드에 있고 동작하지만 **지금은 켜지 않는다** —
`AZURE_STORAGE_CONNECTION_STRING` 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다. `AZURE_STORAGE_CONNECTION_STRING` 을 비워 두면 발행 잡이 업로드 단계를 건너뛴다.

View File

@ -7,6 +7,8 @@
여기서는 그 앞뒤를 잇는다. 여기서는 그 앞뒤를 잇는다.
- 수집이 **무엇을 어디서 가져오는지**는 [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md). - 수집이 **무엇을 어디서 가져오는지**는 [COLLECTION_SEO_AEO_FLOW.md](COLLECTION_SEO_AEO_FLOW.md).
- 표를 고치는 절차는 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md). - 표를 고치는 절차는 [postgres-init/migrations/README.md](../postgres-init/migrations/README.md).
- Google 제출/색인 관측은 `site_search_status`의 별도 상태다. 발행 상태와 섞지 않는다.
컬럼 의미·조회·설정은 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md).
정의는 두 곳이고 **둘 다 최신이어야 한다** — ORM(`solution/backend/common/database/model/models.py`) 정의는 두 곳이고 **둘 다 최신이어야 한다** — ORM(`solution/backend/common/database/model/models.py`)
과 DDL(`postgres-init/init-data/init.sql` + `migrations/`). 컬럼 주석은 ORM 이 더 자세하다. 과 DDL(`postgres-init/init-data/init.sql` + `migrations/`). 컬럼 주석은 ORM 이 더 자세하다.

View File

@ -5,6 +5,15 @@
--- ---
## 2026-09-15 — Google 사이트맵 자동 제출·색인 관측
- 기존 스케줄러에서 발행 완료 DB 감지 → 사이트맵 제출 → 색인 조회 → 지연/실패 알림.
- 관측값·재시도·알림 시각은 `site_search_status`에 보관. 발행 잡/상태는 건드리지 않는다.
- API 인증/호출과 DB·배치·알림 모듈 분리. Google·Teams 실호출은 설정 전까지 꺼진다.
- 설정/적용/관측 의미: [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md). 운영 배포·권한 부여는 미실행.
**검증** — 관련 59건 통과. 추가 회귀 23건 통과·기존 발행 검수 실패 1건(변경 전 코드에서도 재현).
## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다) ## 2026-09-14 — 엽서 쓰기를 발행본에도 넣는다 (사진이 남의 도메인이면 저장·공유는 막힌다)
**무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를 **무슨 일** — 시연본에만 주입 스크립트로 있던 '엽서 쓰기'(사진 고르기 + 한 마디 + 캔버스 엽서)를

99
docs/SEARCH_CONSOLE.md Normal file
View File

@ -0,0 +1,99 @@
# Google Search Console 자동 추적
`발행 DB 감지 → 공개 사이트맵 확인/제출 → 색인 조회 → 상태 저장·Teams 알림`
## 경계
- 기존 API의 스케줄러에서 10분마다 실행한다. 컨테이너 추가 없음.
- `sites.status=PUBLISHED`인 사이트만 등록하므로 초안/목업 디렉토리 나열을 작업 원장으로 쓰지 않는다.
- 발행 DB에서 재발견한다. 발행 순간 별도 큐 적재가 실패하는 틈이 없고 재시작해도 이어진다.
- 발행 트랜잭션/잡과 독립적이다. Google 실패가 사이트 발행을 실패로 바꾸지 않는다.
- 한 번에 신규 발행 100개 등록, 조회는 오래 기다린 5개 처리. 정상 조회는 24시간 후 반복.
- 현재 렌더러의 단일 루트 urlset만 지원하고 읽기 상한은 5MB다. 향후 sitemap index 분할 시 확장한다.
- 오류는 1·2·4·8·16·24시간 간격 재시도. 기본 주기 기준 하루 최대 720회 검사이며,
다른 도구의 같은 속성 사용량도 Google 할당량에 포함된다. 대량 백로그는 여러 날에 걸쳐 소진한다.
- PostgreSQL transaction advisory lock으로 다중 API 프로세스의 동시 배치를 막는다.
단일 배치는 외부 호출 동안 트랜잭션/연결 1개를 점유한다(검사 1건 최대 90초, 최대 5건).
- 사이트맵 제출 성공과 URL 색인 성공은 별개다. `first_indexed_at`은 **우리가 처음 PASS를 관측한 시각**이다.
Google 내부 색인 시각이나 최신 발행 버전 반영 시각이 아니다. 원본 `lastCrawlTime`도 함께 보관한다.
- 재발행 시 해당 발행의 관측 상태를 초기화한다. 지난 관측 이력 전체를 누적하는 이벤트 저장소는 아니다.
- `SITE_PUBLIC_HOST` 변경은 기존 지침대로 재발행이 필요하다. 사이트 주소의 단일 출처는 `site_payload`다.
## 최초 설정 (운영자)
1. Search Console에서 발행 도메인의 소유권 확인. URL-prefix 속성이면
`https://web4ai.o2osolution.ai/`, 도메인 속성이면 `sc-domain:web4ai.o2osolution.ai` 형태.
2. Google Cloud에서 Search Console API 활성화, 전용 서비스 계정 생성.
3. Search Console 속성 설정 → 사용자 및 권한에서 그 서비스 계정 이메일에 전체 사용자 권한 부여.
Google 로그인용 `GOOGLE_CLIENT_ID`와는 다른 인증이다.
4. 서비스 계정 JSON 키는 **저장소 밖**에 보관한다. 권한을 최소화하고 git/이미지/로그에 넣지 않는다.
5. 루트 `.env` 설정:
```dotenv
GSC_ENABLED=1
GSC_PROPERTY_URL=https://web4ai.o2osolution.ai/
GSC_CREDENTIALS_HOST_FILE=/secure/location/search-console.json
GSC_ALERT_DAYS=7
GSC_ALERT_WEBHOOK_URL=
```
키 생성/권한 부여/실제 알림 전송은 구현 검증 중 자동 수행하지 않는다.
## 배포
먼저 새 이미지에 requirements를 설치하고 `0014_search_console.sql`을 기존 마이그레이션 도구로 적용한다.
프로젝트 전체 마이그레이션 순서를 확인한 뒤 실행한다. 아래는 운영자가 실행할 명령이며 자동 배포하지 않았다.
```bash
docker compose exec -T solution-backend python scripts/migrate.py
docker compose -f docker-compose.yml -f docker-compose.search-console.yml up -d --build solution-backend
```
선택 compose 파일은 API에만 키를 읽기 전용 마운트하고 `GSC_CREDENTIALS_FILE`을 설정한다.
없는 파일을 디렉토리로 자동 생성하지 않는다. 이후 배포에서도 이 override를 함께 사용해야 한다.
로컬 Python 실행은 `GSC_CREDENTIALS_FILE`에 로컬 키 파일 경로를 지정한다.
켜진 스케줄러는 첫 10분 주기부터 기존 발행 사이트도 등록한다. `GSC_ENABLED=0`이면 DB/Google 호출 모두 생략한다.
## 알림
Teams Workflows의 webhook 수신 → 채널에 Adaptive Card 게시 흐름 URL을
`GSC_ALERT_WEBHOOK_URL`에 넣는다. 비우면 외부 전송 없이 경고 로그/DB만 남는다.
API/사이트맵 오류 또는 발행 후 기본 7일 미색인 시 알린다. 성공한 알림은 사이트별 24시간 중복 억제.
전송 실패는 `alerted_at`을 갱신하지 않아 다음 검사 때 재시도한다.
외부 전송 후 DB commit 전에 죽으면 중복 알림이 가능하다(at-least-once).
키·토큰·webhook URL·Google 오류 본문은 알림에 포함하지 않는다.
## 결과 확인
```bash
docker compose exec -T solution-backend python scripts/search_console_status.py
```
읽기 전용이며 Google API를 추가 호출하지 않는다. 프론트 화면/API 계약은 변경하지 않았다.
| 파일 | 책임 |
|---|---|
| `services/search_console_client.py` | 인증·Google HTTP·오류 정규화 |
| `services/search_console_settings.py` | 선택 설정·속성 URL 범위 |
| `services/search_console_service.py` | 배치 흐름·재시도·관측 결과 |
| `crud/search_console_crud.py` | 발행 감지·등록·조회 순서·동시 실행 잠금 |
| `services/search_console_alerts.py` | 알림 조건·Teams 전송 |
## 구글 지원 범위 / 남은 운영 작업
- [사이트맵 제출 API](https://developers.google.com/webmaster-tools/v1/sitemaps/submit)는 지원된다.
- [URL Inspection API](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect)는
Google이 이미 알고 있는 상태 조회용이며 실시간 페이지 테스트나 색인 요청 API가 아니다.
- 일반 숙박 사이트는 [Indexing API](https://developers.google.com/search/apis/indexing-api/v3/using-api) 대상이 아니다.
- [검사 할당량](https://developers.google.com/webmaster-tools/limits)은 속성당 하루 2,000회다.
- [Teams webhook 형식](https://learn.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/add-incoming-webhook).
- 실제 서비스 계정 권한/사이트맵 제출/색인 관측/Teams 수신은 설정 후 운영 검증이 필요하다.
- 기존 루트 사이트맵의 백업 URL 정리와 IndexNow 개별 사이트맵 참조 문제는 이 기능과 별도다.
이 기능은 기존 공개 사이트맵을 제출하며 내용을 다시 만들거나 목업을 삭제하지 않는다.
## 구현 검증 (2026-09-15)
- 격리 PostgreSQL에서 클라이언트·배치·스키마·IndexNow 관련 59건 통과.
- 발행·설정·사이트 목록 회귀검사: 23건 통과, `test_unverified_fact_blocks_publish` 1건 실패.
해당 실패는 변경 전 HEAD `9773bc0`의 발행 코드에서도 동일 재현됨(GSC 비활성).
- Google/Teams 실호출 없음. 서비스 계정 권한·실제 제출·채널 수신은 운영 설정 후 검증 대상.

View File

@ -0,0 +1,71 @@
# Search Console 클라이언트
`solution/backend/services/search_console_client.py` — Google Search Console 에
사이트맵을 제출하고 URL 색인 상태를 조회하는 REST 클라이언트만 다룬다.
DB 저장·스케줄링·발행 감지·환경 설정은 [SEARCH_CONSOLE.md](SEARCH_CONSOLE.md)를 따른다.
공식 문서: [Sitemaps.submit](https://developers.google.com/webmaster-tools/v1/sitemaps/submit) ·
[urlInspection.index.inspect](https://developers.google.com/webmaster-tools/v1/urlInspection.index/inspect)
## 1. 계약
```python
class SearchConsoleClient:
def __init__(self, credentials_file: str, *, transport: httpx.AsyncBaseTransport | None = None): ...
async def submit_sitemap(self, property_url: str, sitemap_url: str) -> None: ...
async def inspect_url(self, property_url: str, page_url: str) -> dict: ... # indexStatusResult 만
async def aclose(self) -> None: ...
# async with SearchConsoleClient(...) as client: ...
```
- `credentials_file`: 서비스 계정 JSON 키 파일 경로.
- `transport`: 테스트에서 `httpx.MockTransport` 를 꽂는 자리 — 실제 Google 호출 없이 검증한다.
- `inspect_url` 은 응답의 `inspectionResult.indexStatusResult` 만 돌려준다. 그 경로가
없거나(검사 실패) 빈 dict 면(실제 검사가 안 된 응답) `SearchConsoleError` 를 올린다 —
**"미색인"으로 넘겨짚지 않는다.**
## 2. 인증
서비스 계정 JSON 키 파일 + scope `https://www.googleapis.com/auth/webmasters`.
`google.oauth2.service_account` · `google.auth.transport.requests.Request` · `requests` 는
전부 함수 안에서 import 한다. 두 패키지는 백엔드 `requirements.txt`에 포함되어 있다.
- 토큰은 클라이언트 인스턴스에 캐시된다(`credentials.valid` 인 동안 재사용, 매 호출
갱신하지 않는다). 동시 호출은 `asyncio.Lock` 으로 갱신을 한 번만 태운다.
- 갱신은 `asyncio.to_thread` 로 별도 스레드에서 돈다. 내부 `requests.Session` 요청에는
타임아웃을 강제로 20초로 덮어씌운다(`Request.__call__` 기본값 120초를 무시) — 만료된
키·막힌 네트워크에서 무한정 걸리는 것을 막는다.
## 3. 오류 — `SearchConsoleError(code)`
`code` 문자열 하나만 들고 다닌다. **Google 응답 본문·액세스 토큰·키 파일 내용·원본 예외
메시지는 절대 담지 않는다** — 로그·잡 상태·관리 화면 어디로 흘러도 안전하다.
| code | 뜻 |
|---|---|
| `invalid_credentials_file` | 키 파일을 못 읽거나 형식이 잘못됨 |
| `auth_failed` | 토큰 갱신 실패, 또는 갱신 후에도 토큰이 비어 있음 |
| `unauthorized` | HTTP 401 |
| `forbidden` | HTTP 403 |
| `rate_limited` | HTTP 429 |
| `server_error` | HTTP 5xx |
| `http_<code>` | 그 외 실패 상태코드 |
| `timeout` | 요청 타임아웃 |
| `transport_error` | 그 외 전송 실패(연결 끊김 등) |
| `invalid_json` | 200 인데 본문이 JSON 이 아님 |
| `missing_inspection_result` | 응답에 `inspectionResult` 가 없음 |
| `missing_index_status_result` | `inspectionResult` 는 있는데 `indexStatusResult` 가 없거나 빈 dict |
## 4. 테스트
```bash
cd solution/backend
APP_ENV=test .venv/bin/python -m pytest tests/test_search_console_client.py --confcutdir=tests
```
`--confcutdir=tests` 가 필요한 이유: 저장소 루트 `conftest.py` 의 세션 스코프 autouse
픽스처가 실 Postgres 연결을 요구한다(`solution/backend/conftest.py`). 이 클라이언트
테스트는 DB 를 전혀 쓰지 않으므로 그 픽스처를 건너뛴다 — `--confcutdir=tests` 로 상위
`conftest.py` 탐색을 끊는다. (통합 후 전체 스위트를 돌릴 때는 이 플래그 없이 실행한다.)
Google 실 API 는 전부 `httpx.MockTransport` 로 막았다 — 네트워크 호출도, 과금도 없다.

11
docs/TEAMS_WEBHOOK.md Normal file
View File

@ -0,0 +1,11 @@
# Teams 웹훅 확인 (2026-09-15)
- Adaptive Card 요청의 `contentUrl: null` 및 `$schema`를 공식 예제에 맞춰 보완했다.
- HTTP 202는 워크플로의 요청 접수다. Teams 채널 게시 성공을 뜻하지 않는다.
- 실제 전송 2건은 202였지만 사용자가 확인한 워크플로 실행은 실패였다.
상세 오류를 확인하지 못했으므로 누락 필드를 실제 실패 원인으로 단정하지 않는다.
- 운영 자동 알림 활성화 전, 채널 수신 또는 워크플로의 최종 게시 단계 성공을 확인해야 한다.
- 웹훅은 `.env`에만 보관하고 커밋하지 않는다.
검증: 백엔드에서 `APP_ENV=test PYTHONPATH=. .venv/bin/pytest tests/test_search_console_alerts.py --confcutdir=tests`.
공식 형식: https://learn.microsoft.com/en-us/connectors/teams/#adaptivecarditemschema

View File

@ -317,6 +317,25 @@ CREATE TABLE IF NOT EXISTS public.place_area_refs (
-- ★ 정적 빌드 — DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다. -- ★ 정적 빌드 — DB 는 빌드 시점에만 읽고 방문자와 만나지 않는다.
-- ★ 해지는 물리 삭제가 아니라 상태 전이다. 색인된 페이지를 갑자기 404 로 만들지 않는다. -- ★ 해지는 물리 삭제가 아니라 상태 전이다. 색인된 페이지를 갑자기 404 로 만들지 않는다.
-- ============================================================ -- ============================================================
CREATE TABLE IF NOT EXISTS public.site_search_status (
site_id uuid PRIMARY KEY,
site_version_id uuid NOT NULL,
property_url TEXT NOT NULL,
page_url TEXT NOT NULL,
published_at TIMESTAMPTZ NOT NULL,
sitemap_submitted_at TIMESTAMPTZ NULL,
inspected_at TIMESTAMPTZ NULL,
first_indexed_at TIMESTAMPTZ NULL,
inspection JSONB NULL,
error_code VARCHAR(100) NULL,
failures INTEGER NOT NULL DEFAULT 0,
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
alerted_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT false
);
CREATE TABLE IF NOT EXISTS public.sites ( CREATE TABLE IF NOT EXISTS public.sites (
site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), site_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
place_id uuid NOT NULL, -- 사업장과 1:1 place_id uuid NOT NULL, -- 사업장과 1:1

View File

@ -0,0 +1,19 @@
-- 발행 상태를 바꾸지 않고 Google 제출/검사 결과를 추적한다.
CREATE TABLE IF NOT EXISTS public.site_search_status (
site_id uuid PRIMARY KEY,
site_version_id uuid NOT NULL,
property_url TEXT NOT NULL,
page_url TEXT NOT NULL,
published_at TIMESTAMPTZ NOT NULL,
sitemap_submitted_at TIMESTAMPTZ NULL,
inspected_at TIMESTAMPTZ NULL,
first_indexed_at TIMESTAMPTZ NULL,
inspection JSONB NULL,
error_code VARCHAR(100) NULL,
failures INTEGER NOT NULL DEFAULT 0,
next_check_at TIMESTAMPTZ NOT NULL DEFAULT now(),
alerted_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT false
);

View File

@ -454,6 +454,25 @@ class sites(MainTableMixin, MAIN_BASE):
thumbnail_url = Column(String(500), nullable=True) thumbnail_url = Column(String(500), nullable=True)
class site_search_status(MainTableMixin, MAIN_BASE):
"""발행 성공과 Google 색인 성공은 다른 사건이라 별도 보관한다."""
__tablename__ = "site_search_status"
site_id = Column(UUID(as_uuid=True), primary_key=True)
site_version_id = Column(UUID(as_uuid=True), nullable=False)
property_url = Column(Text, nullable=False)
page_url = Column(Text, nullable=False)
published_at = Column(DateTime(timezone=True), nullable=False)
sitemap_submitted_at = Column(DateTime(timezone=True), nullable=True)
inspected_at = Column(DateTime(timezone=True), nullable=True)
first_indexed_at = Column(DateTime(timezone=True), nullable=True)
inspection = Column(JSONB, nullable=True)
error_code = Column(String(100), nullable=True)
failures = Column(Integer, nullable=False, server_default=text("0"))
next_check_at = Column(DateTime(timezone=True), nullable=False, server_default=_utc_now_sql())
alerted_at = Column(DateTime(timezone=True), nullable=True)
class site_sections(MainTableMixin, MAIN_BASE): class site_sections(MainTableMixin, MAIN_BASE):
"""섹션 하나의 콘텐츠. **JSON import/export 의 단위**다. """섹션 하나의 콘텐츠. **JSON import/export 의 단위**다.

View File

@ -0,0 +1,57 @@
"""발행 DB를 작업 원장으로 사용해 알림 적재 실패/재시작에도 대상을 다시 찾는다."""
from sqlalchemy import select, or_, text
from sqlalchemy.dialects.postgresql import insert
from common.database.model.models import sites, places, site_versions, site_search_status as Status
from common.enums import SiteStatus
def published_conditions():
return (
sites.deleted.is_(False), places.deleted.is_(False),
sites.status == SiteStatus.PUBLISHED.value,
sites.published_at.is_not(None), sites.current_version_id.is_not(None),
)
async def lock_batch(session) -> bool:
# 여러 API 프로세스가 같은 크론을 등록해도 외부 호출은 한 곳만 수행한다.
return bool(await session.scalar(text("SELECT pg_try_advisory_xact_lock(734920151)")))
async def new_publications(session, property_url: str):
result = await session.execute(
select(sites, places, site_versions)
.join(places, places.place_id == sites.place_id)
.join(site_versions, site_versions.site_version_id == sites.current_version_id)
.outerjoin(Status, Status.site_id == sites.site_id)
.where(*published_conditions(), site_versions.deleted.is_(False), or_(
Status.site_id.is_(None), Status.site_version_id != sites.current_version_id,
Status.property_url != property_url, Status.published_at != sites.published_at,
))
.order_by(sites.published_at).limit(100)
)
return result.all()
async def register(session, values: dict):
reset = dict(values, sitemap_submitted_at=None, inspected_at=None, first_indexed_at=None,
inspection=None, error_code=None, failures=0, alerted_at=None,
next_check_at=text("now()"), updated_at=text("now()"), deleted=False)
await session.execute(insert(Status).values(**values).on_conflict_do_update(
index_elements=[Status.site_id], set_=reset,
))
async def due_sites(session, property_url: str):
result = await session.execute(
select(Status).join(sites, sites.site_id == Status.site_id)
.join(places, places.place_id == sites.place_id)
.where(*published_conditions(), Status.deleted.is_(False),
Status.site_version_id == sites.current_version_id,
Status.published_at == sites.published_at, Status.property_url == property_url,
Status.next_check_at <= text("now()"))
.order_by(Status.next_check_at).limit(5)
.with_for_update(of=Status)
)
return result.scalars().all()

View File

@ -9,6 +9,8 @@ orjson
pydantic>=2.0 pydantic>=2.0
python-multipart python-multipart
httpx httpx
google-auth>=2.0 # Search Console 서비스 계정 인증 (선택 기능)
requests>=2.31 # google-auth 토큰 갱신 transport
apscheduler>=3.10 apscheduler>=3.10
pydantic-settings # 환경변수·.env 로드 (FastAPI 공식 설정 방식) pydantic-settings # 환경변수·.env 로드 (FastAPI 공식 설정 방식)
azure-storage-blob>=12.19 azure-storage-blob>=12.19

View File

@ -2,7 +2,7 @@
다중 워커(운영)에서 잡이 워커마다 중복 실행되면 안 되므로 SCHEDULER_ENABLED=1 인 프로세스에서만 등록한다. 다중 워커(운영)에서 잡이 워커마다 중복 실행되면 안 되므로 SCHEDULER_ENABLED=1 인 프로세스에서만 등록한다.
등록된 잡: 없음(스켈레톤). 붙을 잡 — 등록된 잡: Search Console (GSC_ENABLED=1, 10분마다). 붙을 잡 —
· 지역정보 갱신 : 축제 주 1회 / 관광정보 월 1회 / 날씨 시간 단위 — 행정구역 코드 단위 캐시 갱신 · 지역정보 갱신 : 축제 주 1회 / 관광정보 월 1회 / 날씨 시간 단위 — 행정구역 코드 단위 캐시 갱신
· 수집 재시도 : 실패한 수집 작업 재시도 (외부 API 실패 시 직전 값 유지 + 내부 알림) · 수집 재시도 : 실패한 수집 작업 재시도 (외부 API 실패 시 직전 값 유지 + 내부 알림)
· 사이트 재빌드 : 검증 상태가 바뀐 place 만 개별 재빌드 (전체 재빌드 금지) · 사이트 재빌드 : 검증 상태가 바뀐 place 만 개별 재빌드 (전체 재빌드 금지)
@ -33,8 +33,12 @@ def start_scheduler():
# 한국시간 기준. 잡은 scheduler/jobs.py 에 정의하고 여기서 add_job 으로 등록한다. # 한국시간 기준. 잡은 scheduler/jobs.py 에 정의하고 여기서 add_job 으로 등록한다.
_scheduler = AsyncIOScheduler(timezone="Asia/Seoul") _scheduler = AsyncIOScheduler(timezone="Asia/Seoul")
if os.environ.get("GSC_ENABLED") == "1":
from services.search_console_service import run_scheduled_check
_scheduler.add_job(run_scheduled_check, "interval", minutes=10,
id="search-console", max_instances=1, coalesce=True)
_scheduler.start() _scheduler.start()
LOG.i("[scheduler] started (KST: 등록된 잡 없음)") LOG.i(f"[scheduler] started (KST: {len(_scheduler.get_jobs())}개 잡)")
def shutdown_scheduler(): def shutdown_scheduler():

View File

@ -0,0 +1,32 @@
"""저장된 관측값만 출력한다. Google 호출/색인 요청은 하지 않는다."""
import asyncio
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import site_search_status as Status
from common.enums import DBType
async def main():
async def read(session):
rows = (await session.execute(select(Status).where(Status.deleted.is_(False))
.order_by(Status.published_at.desc()))).scalars().all()
return [{"url": row.page_url, "published_at": row.published_at,
"sitemap_submitted_at": row.sitemap_submitted_at,
"inspected_at": row.inspected_at, "first_indexed_at": row.first_indexed_at,
"inspection": row.inspection, "error_code": row.error_code,
"next_check_at": row.next_check_at, "alerted_at": row.alerted_at} for row in rows]
try:
rows = await DB_SESSION_MNG.execute_lambda_write(DBType.MAIN.value, read)
print(json.dumps(rows, ensure_ascii=False, indent=2, default=str))
finally:
await DB_SESSION_MNG.dispose_all()
if __name__ == "__main__":
asyncio.run(main())

View File

@ -0,0 +1,45 @@
"""Teams Workflow 수신용. 원문 오류/인증 정보는 메시지에 싣지 않는다."""
from datetime import timedelta
import httpx
from common.logger import LOG
def alert_reason(row, now, days: int) -> str | None:
if row.alerted_at and now - row.alerted_at < timedelta(days=1):
return None
if row.error_code:
return f"조회/제출 실패: {row.error_code}"
if (row.inspection or {}).get("verdict") == "PASS":
return None
if now - row.published_at >= timedelta(days=days):
return f"발행 후 {days}일 이상 색인 미확인"
return None
async def send_alert(webhook_url: str, page_url: str, reason: str) -> bool:
if not webhook_url:
return False
if not webhook_url.startswith("https://"):
LOG.w("[search-console] ALERT_URL_INVALID")
return False
payload = {"type": "message", "attachments": [{
"contentType": "application/vnd.microsoft.card.adaptive",
"contentUrl": None,
"content": {"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard", "version": "1.2", "body": [
{"type": "TextBlock", "text": "Google 색인 확인 필요", "weight": "Bolder"},
{"type": "TextBlock", "text": page_url, "wrap": True},
{"type": "TextBlock", "text": reason, "wrap": True},
]},
}]}
try:
async with httpx.AsyncClient(timeout=10, follow_redirects=False) as client:
response = await client.post(webhook_url, json=payload)
response.raise_for_status()
return True
except httpx.HTTPError:
# webhook 주소 자체가 인증 수단이므로 예외 문자열도 로그에 남기지 않는다.
LOG.w("[search-console] ALERT_DELIVERY_FAILED")
return False

View File

@ -0,0 +1,164 @@
"""Google Search Console REST 클라이언트 — 사이트맵 제출 · URL 색인 상태 조회.
PUT https://www.googleapis.com/webmasters/v3/sites/{siteUrl}/sitemaps/{feedpath}
POST https://searchconsole.googleapis.com/v1/urlInspection/index:inspect
자세한 계약은 docs/SEARCH_CONSOLE_CLIENT.md.
★ `SearchConsoleError` 는 `code` 문자열만 담는다 — Google 응답 본문·토큰·키·원본
예외 메시지는 절대 싣지 않는다.
★ `inspectionResult`·`indexStatusResult` 가 없거나 빈 응답은 "미색인"으로
넘겨짚지 않고 예외로 끊는다 — 검사 실패와 색인 결과를 구분 못하면 오판이 된다.
"""
from __future__ import annotations
import asyncio
from typing import Any
from urllib.parse import quote
import httpx
SITEMAPS_BASE = "https://www.googleapis.com/webmasters/v3/sites"
INSPECT_URL = "https://searchconsole.googleapis.com/v1/urlInspection/index:inspect"
SCOPES = ["https://www.googleapis.com/auth/webmasters"]
TIMEOUT_SEC = 20.0
AUTH_REFRESH_TIMEOUT_SEC = 20.0
class SearchConsoleError(RuntimeError):
"""code 만 담는다 — Google 응답 본문·토큰·키는 여기 실으면 안 된다."""
def __init__(self, code: str):
self.code = code
super().__init__(code)
def _service_account_credentials(credentials_file: str):
"""lazy import — 미설치여도 이 모듈의 import 자체는 죽지 않는다."""
from google.oauth2 import service_account
return service_account.Credentials.from_service_account_file(credentials_file, scopes=SCOPES)
def _refresh_sync(credentials) -> None:
"""동기 토큰 갱신. `asyncio.to_thread` 로 감싸 부른다.
`Request.__call__` 기본 타임아웃(120s)을 그대로 두면 만료된 키·막힌 네트워크에서
오래 걸릴 수 있다 — 매 요청에 상한을 강제로 덮어씌운다(호출측이 넘긴 값 포함)."""
import requests
from google.auth.transport.requests import Request
with requests.Session() as session:
original_request = session.request
def _request_with_timeout(*args, **kwargs):
kwargs["timeout"] = AUTH_REFRESH_TIMEOUT_SEC
return original_request(*args, **kwargs)
session.request = _request_with_timeout
credentials.refresh(Request(session=session))
def _status_error_code(status_code: int) -> str:
if status_code == 401:
return "unauthorized"
if status_code == 403:
return "forbidden"
if status_code == 429:
return "rate_limited"
if 500 <= status_code < 600:
return "server_error"
return f"http_{status_code}"
def _transport_error_code(ex: httpx.HTTPError) -> str:
if isinstance(ex, httpx.TimeoutException):
return "timeout"
return "transport_error"
class SearchConsoleClient:
def __init__(self, credentials_file: str, *, transport: httpx.AsyncBaseTransport | None = None):
self._credentials_file = credentials_file
self._client = httpx.AsyncClient(timeout=TIMEOUT_SEC, transport=transport)
self._credentials: Any = None
self._auth_lock = asyncio.Lock()
async def __aenter__(self) -> "SearchConsoleClient":
return self
async def __aexit__(self, exc_type, exc, tb) -> None:
await self.aclose()
async def aclose(self) -> None:
await self._client.aclose()
async def submit_sitemap(self, property_url: str, sitemap_url: str) -> None:
url = f"{SITEMAPS_BASE}/{quote(property_url, safe='')}/sitemaps/{quote(sitemap_url, safe='')}"
headers = await self._auth_headers()
res = await self._put(url, headers)
self._raise_for_status(res)
async def inspect_url(self, property_url: str, page_url: str) -> dict[str, Any]:
headers = await self._auth_headers()
body = {"inspectionUrl": page_url, "siteUrl": property_url}
res = await self._post(INSPECT_URL, headers, body)
self._raise_for_status(res)
return _index_status_result(res)
async def _put(self, url: str, headers: dict[str, str]) -> httpx.Response:
try:
return await self._client.put(url, headers=headers)
except httpx.HTTPError as ex:
raise SearchConsoleError(_transport_error_code(ex)) from None
async def _post(self, url: str, headers: dict[str, str], body: dict[str, Any]) -> httpx.Response:
try:
return await self._client.post(url, headers=headers, json=body)
except httpx.HTTPError as ex:
raise SearchConsoleError(_transport_error_code(ex)) from None
def _raise_for_status(self, res: httpx.Response) -> None:
if not res.is_success:
raise SearchConsoleError(_status_error_code(res.status_code))
async def _auth_headers(self) -> dict[str, str]:
token = await self._access_token()
return {"Authorization": f"Bearer {token}"}
async def _access_token(self) -> str:
credentials = self._load_credentials()
if not getattr(credentials, "valid", False):
async with self._auth_lock:
if not getattr(credentials, "valid", False):
try:
await asyncio.to_thread(_refresh_sync, credentials)
except Exception:
raise SearchConsoleError("auth_failed") from None
token = getattr(credentials, "token", None)
if not token:
raise SearchConsoleError("auth_failed")
return token
def _load_credentials(self) -> Any:
if self._credentials is None:
try:
self._credentials = _service_account_credentials(self._credentials_file)
except Exception:
raise SearchConsoleError("invalid_credentials_file") from None
return self._credentials
def _index_status_result(res: httpx.Response) -> dict[str, Any]:
try:
data = res.json()
except ValueError:
raise SearchConsoleError("invalid_json") from None
inspection_result = data.get("inspectionResult") if isinstance(data, dict) else None
if not isinstance(inspection_result, dict):
raise SearchConsoleError("missing_inspection_result")
index_status_result = inspection_result.get("indexStatusResult")
if not isinstance(index_status_result, dict) or not index_status_result:
raise SearchConsoleError("missing_index_status_result")
return index_status_result

View File

@ -0,0 +1,129 @@
"""발행 감지 → 사이트맵 제출 → 색인 조회 → 알림. 발행 잡과 별도 트랜잭션이다."""
import asyncio
import xml.etree.ElementTree as ET
from datetime import datetime, timedelta, timezone
import httpx
from common.database.db_session_manager import DB_SESSION_MNG
from common.enums import DBType
from common.logger import LOG
from crud import search_console_crud as store
from services import site_payload
from services.search_console_alerts import alert_reason, send_alert
from services.search_console_client import SearchConsoleClient, SearchConsoleError
from services.search_console_settings import load_settings, belongs_to_property
async def run_scheduled_check():
try:
settings = load_settings()
if settings is None:
return
await DB_SESSION_MNG.execute_lambda_write(
DBType.MAIN.value, lambda session: run_batch(session, settings),
)
except Exception:
# 크론 실패가 API/발행에 전파되지 않고 다음 주기에 재시도된다.
LOG.w("[search-console] BATCH_FAILED — 설정 및 DB 마이그레이션 확인 필요")
async def run_batch(session, settings):
if not await store.lock_batch(session):
return
if not belongs_to_property(site_payload.publish_origin() + "/sitemap.xml", settings.property_url):
raise SearchConsoleError("PROPERTY_MISMATCH")
await register_publications(session, settings)
rows = await store.due_sites(session, settings.property_url)
if not rows:
return
async with SearchConsoleClient(settings.credentials_file) as client:
submitted = set()
for row in rows:
await check_site(client, row, settings, submitted)
await session.flush()
async def register_publications(session, settings):
for site, place, version in await store.new_publications(session, settings.property_url):
slug = site_payload.publish_slug(place, site)
page_url = f"{site_payload.publish_origin()}/s/{slug}"
if not belongs_to_property(page_url, settings.property_url):
LOG.w("[search-console] PROPERTY_MISMATCH")
continue
await store.register(session, {
"site_id": site.site_id, "site_version_id": version.site_version_id,
"property_url": settings.property_url, "page_url": page_url,
"published_at": site.published_at,
})
async def read_sitemap(sitemap_url: str) -> set[str]:
try:
async with httpx.AsyncClient(timeout=20, follow_redirects=False) as client:
async with client.stream("GET", sitemap_url) as response:
response.raise_for_status()
body = bytearray()
async for chunk in response.aiter_bytes():
body.extend(chunk)
if len(body) > 5_000_000:
raise SearchConsoleError("SITEMAP_TOO_LARGE")
root = ET.fromstring(body)
return {(item.text or "").strip() for item in root.iter(
"{http://www.sitemaps.org/schemas/sitemap/0.9}loc"
)}
except (httpx.HTTPError, ET.ParseError):
raise SearchConsoleError("SITEMAP_UNAVAILABLE") from None
async def submit_sitemap(client, row, submitted: set):
if row.sitemap_submitted_at:
return
sitemap_url = site_payload.publish_origin() + "/sitemap.xml"
# 디스크가 아니라 실제 공개 URL을 확인한다. 업로드 지연 시 Google에 먼저 알리지 않는다.
urls = await read_sitemap(sitemap_url)
if row.page_url not in urls:
raise SearchConsoleError("URL_NOT_IN_SITEMAP")
if sitemap_url not in submitted:
await client.submit_sitemap(row.property_url, sitemap_url)
submitted.add(sitemap_url)
row.sitemap_submitted_at = datetime.now(timezone.utc)
def record_inspection(row, inspection: dict, now):
row.inspection = inspection
row.inspected_at = now
row.error_code = None
row.failures = 0
if inspection.get("verdict") == "PASS":
row.first_indexed_at = row.first_indexed_at or now
row.next_check_at = now + timedelta(days=1)
def record_error(row, code: str, now):
row.error_code = code
row.failures = (row.failures or 0) + 1
hours = min(24, 2 ** min(row.failures - 1, 5))
row.next_check_at = now + timedelta(hours=hours)
async def check_site(client, row, settings, submitted: set):
now = datetime.now(timezone.utc)
try:
if not belongs_to_property(row.page_url, settings.property_url):
raise SearchConsoleError("PROPERTY_MISMATCH")
async with asyncio.timeout(90):
await submit_sitemap(client, row, submitted)
inspection = await client.inspect_url(row.property_url, row.page_url)
record_inspection(row, inspection, now)
except SearchConsoleError as ex:
record_error(row, ex.code, now)
except TimeoutError:
record_error(row, "CHECK_TIMEOUT", now)
except Exception:
record_error(row, "CHECK_FAILED", now)
reason = alert_reason(row, now, settings.alert_days)
if reason:
LOG.w(f"[search-console] site={row.site_id} {reason}")
if await send_alert(settings.alert_url, row.page_url, reason):
row.alerted_at = now

View File

@ -0,0 +1,43 @@
"""Google 로그인 설정과 분리한다. 서비스 계정 키는 서버 파일로만 읽는다."""
import os
from dataclasses import dataclass
from urllib.parse import urlsplit
@dataclass(frozen=True)
class SearchConsoleSettings:
property_url: str
credentials_file: str
alert_url: str = ""
alert_days: int = 7
def load_settings() -> SearchConsoleSettings | None:
if os.environ.get("GSC_ENABLED") != "1":
return None
prop = os.environ.get("GSC_PROPERTY_URL", "").strip()
key_file = os.environ.get("GSC_CREDENTIALS_FILE", "").strip()
if not prop or not key_file:
raise ValueError("GSC_CONFIG_MISSING")
parsed = urlsplit(prop)
domain_property = prop.startswith("sc-domain:") and bool(prop.removeprefix("sc-domain:"))
root_property = (parsed.scheme == "https" and parsed.hostname and parsed.path == "/"
and not parsed.query and not parsed.fragment and not parsed.username)
if not domain_property and not root_property:
raise ValueError("GSC_PROPERTY_INVALID")
days = int(os.environ.get("GSC_ALERT_DAYS", "7"))
if days < 1:
raise ValueError("GSC_ALERT_DAYS_INVALID")
return SearchConsoleSettings(prop, key_file, os.environ.get("GSC_ALERT_WEBHOOK_URL", ""), days)
def belongs_to_property(page_url: str, property_url: str) -> bool:
parsed = urlsplit(page_url)
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
return False
if parsed.query or parsed.fragment:
return False
if property_url.startswith("sc-domain:"):
domain = property_url.removeprefix("sc-domain:").lower()
return bool(domain) and (parsed.hostname == domain or parsed.hostname.endswith("." + domain))
return page_url.startswith(property_url)

View File

@ -0,0 +1,21 @@
import httpx
from services import search_console_alerts as alerts
async def test_teams_payload_has_required_attachment_fields(monkeypatch):
requests = []
real_client = httpx.AsyncClient
def receive(request):
import json
requests.append(json.loads(request.content))
return httpx.Response(202)
monkeypatch.setattr(alerts.httpx, "AsyncClient", lambda **kw: real_client(
transport=httpx.MockTransport(receive), **kw,
))
assert await alerts.send_alert("https://example.test/webhook", "https://example.test/s/a", "test")
card = requests[0]["attachments"][0]
assert card["contentType"] == "application/vnd.microsoft.card.adaptive"
assert "contentUrl" in card and card["contentUrl"] is None
assert card["content"]["$schema"] == "http://adaptivecards.io/schemas/adaptive-card.json"

View File

@ -0,0 +1,342 @@
"""Search Console 클라이언트 — 사이트맵 제출 · URL 검사.
★ 실제 Google API 를 호출하지 않는다(httpx.MockTransport). google-auth 도 이
저장소의 의존성이 아니라서(Codex 가 추가) 인증은 모듈 함수
`_service_account_credentials` / `_refresh_sync` 를 monkeypatch 해서 흉내 낸다 —
설치 여부와 무관하게 이 테스트가 돌아야 한다.
여기서 고정하는 것:
1. `inspect_url` 은 `inspectionResult.indexStatusResult` 만 돌려준다
2. ★ 그 필드가 없는 응답을 "미색인"으로 짐작하지 않고 예외를 올린다
3. 오류는 전부 `SearchConsoleError(code)` 로 정규화되고, 원본 예외 메시지·응답
본문이 `code` 에 섞여 나오지 않는다
4. 사이트맵 제출 URL 은 property/sitemap 주소를 완전 percent-encode 한다
"""
import asyncio
import httpx
import pytest
from services import search_console_client as scc
from services.search_console_client import SearchConsoleClient, SearchConsoleError
PROPERTY = "https://example.com/"
SITEMAP = "https://example.com/sitemap.xml"
PAGE = "https://example.com/s/butter/"
def _auth(monkeypatch, *, token="test-token", load_error=None, refresh_error=None):
"""`_service_account_credentials`/`_refresh_sync` 를 흉내내 google-auth 없이 인증을 통과시킨다."""
state = {"loads": 0, "refreshes": 0}
class _FakeCredentials:
def __init__(self):
self.token = None
self.valid = False
def fake_load(path):
state["loads"] += 1
if load_error is not None:
raise load_error
return _FakeCredentials()
def fake_refresh(creds):
state["refreshes"] += 1
if refresh_error is not None:
raise refresh_error
creds.token = token
creds.valid = bool(token)
monkeypatch.setattr(scc, "_service_account_credentials", fake_load)
monkeypatch.setattr(scc, "_refresh_sync", fake_refresh)
return state
def _client(handler, monkeypatch, **auth_kwargs) -> SearchConsoleClient:
_auth(monkeypatch, **auth_kwargs)
return SearchConsoleClient("dummy-credentials.json", transport=httpx.MockTransport(handler))
# ── 1) 사이트맵 제출 ──────────────────────────────────────────────────────
async def test_사이트맵_제출은_URL을_완전_퍼센트인코딩한다(monkeypatch):
"""검증: property/sitemap 주소를 PUT 경로에 싣는다.
기대결과: 두 주소 모두 `/` `:` 까지 percent-encode 되어 경로 세그먼트 하나로 들어간다."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["method"] = request.method
captured["url"] = str(request.url)
captured["auth"] = request.headers.get("Authorization")
return httpx.Response(200)
client = _client(handler, monkeypatch, token="tok-abc")
async with client:
await client.submit_sitemap(PROPERTY, SITEMAP)
assert captured["method"] == "PUT"
assert captured["url"] == (
f"{scc.SITEMAPS_BASE}/https%3A%2F%2Fexample.com%2F"
"/sitemaps/https%3A%2F%2Fexample.com%2Fsitemap.xml"
)
assert captured["auth"] == "Bearer tok-abc"
async def test_사이트맵_제출_성공은_아무것도_돌려주지_않는다(monkeypatch):
"""검증: 2xx 응답(빈 본문).
기대결과: 예외 없이 끝난다 — 반환값은 None."""
def handler(request):
return httpx.Response(200)
client = _client(handler, monkeypatch)
async with client:
assert await client.submit_sitemap(PROPERTY, SITEMAP) is None
# ── 2) URL 검사 ───────────────────────────────────────────────────────────
async def test_URL_검사는_indexStatusResult만_돌려준다(monkeypatch):
"""검증: 응답에 indexStatusResult 외에 mobileUsabilityResult 등 다른 필드도 있다.
기대결과: indexStatusResult 만 뽑아 돌려준다."""
def handler(request: httpx.Request) -> httpx.Response:
assert request.method == "POST"
assert scc.INSPECT_URL in str(request.url)
return httpx.Response(200, json={
"inspectionResult": {
"indexStatusResult": {"verdict": "PASS", "coverageState": "Submitted and indexed"},
"mobileUsabilityResult": {"verdict": "PASS"},
}
})
client = _client(handler, monkeypatch)
async with client:
result = await client.inspect_url(PROPERTY, PAGE)
assert result == {"verdict": "PASS", "coverageState": "Submitted and indexed"}
async def test_URL_검사_요청_본문은_inspectionUrl과_siteUrl이다(monkeypatch):
"""검증: 검사 요청 본문.
기대결과: {"inspectionUrl": 검사할 페이지, "siteUrl": 프로퍼티} 그대로."""
captured = {}
def handler(request: httpx.Request) -> httpx.Response:
captured["body"] = request.content
return httpx.Response(200, json={"inspectionResult": {"indexStatusResult": {"verdict": "PASS"}}})
import json as _json
client = _client(handler, monkeypatch)
async with client:
await client.inspect_url(PROPERTY, PAGE)
assert _json.loads(captured["body"]) == {"inspectionUrl": PAGE, "siteUrl": PROPERTY}
async def test_inspectionResult가_없으면_미색인으로_넘겨짚지_않는다(monkeypatch):
"""검증: ★ 응답 본문이 비어 있다({}).
기대결과: SearchConsoleError("missing_inspection_result") — 빈 dict 를 돌려주지 않는다.
(호출측이 빈 dict 를 "미색인"으로 오판할 수 있으므로 여기서 예외로 끊는다.)"""
def handler(request):
return httpx.Response(200, json={})
client = _client(handler, monkeypatch)
with pytest.raises(SearchConsoleError) as exc:
async with client:
await client.inspect_url(PROPERTY, PAGE)
assert exc.value.code == "missing_inspection_result"
async def test_indexStatusResult가_없으면_예외(monkeypatch):
"""검증: inspectionResult 는 있는데 indexStatusResult 가 없다(다른 결과만 있음).
기대결과: SearchConsoleError("missing_index_status_result")."""
def handler(request):
return httpx.Response(200, json={"inspectionResult": {"mobileUsabilityResult": {}}})
client = _client(handler, monkeypatch)
with pytest.raises(SearchConsoleError) as exc:
async with client:
await client.inspect_url(PROPERTY, PAGE)
assert exc.value.code == "missing_index_status_result"
async def test_indexStatusResult가_빈_객체면_예외(monkeypatch):
"""검증: indexStatusResult 는 있지만 빈 dict({}) — 실제 검사가 안 된 응답이다.
기대결과: SearchConsoleError("missing_index_status_result") — 빈 결과를 색인 상태로 돌려주지 않는다."""
def handler(request):
return httpx.Response(200, json={"inspectionResult": {"indexStatusResult": {}}})
client = _client(handler, monkeypatch)
with pytest.raises(SearchConsoleError) as exc:
async with client:
await client.inspect_url(PROPERTY, PAGE)
assert exc.value.code == "missing_index_status_result"
async def test_비정상_JSON_본문은_invalid_json(monkeypatch):
"""검증: 200 인데 본문이 JSON 이 아니다.
기대결과: SearchConsoleError("invalid_json") — 파싱 실패가 조용히 넘어가지 않는다."""
def handler(request):
return httpx.Response(200, text="<html>not json</html>")
client = _client(handler, monkeypatch)
with pytest.raises(SearchConsoleError) as exc:
async with client:
await client.inspect_url(PROPERTY, PAGE)
assert exc.value.code == "invalid_json"
# ── 3) HTTP 상태 코드 정규화 ─────────────────────────────────────────────
@pytest.mark.parametrize(
("status", "code"),
[(401, "unauthorized"), (403, "forbidden"), (429, "rate_limited"), (500, "server_error"), (503, "server_error")],
)
async def test_HTTP_오류_상태코드는_code로_정규화된다(monkeypatch, status, code):
"""검증: 401/403/429/5xx 응답.
기대결과: SearchConsoleError.code 가 상태별로 정규화되고, Google 응답 본문은 code 에 섞이지 않는다."""
def handler(request):
return httpx.Response(status, text="google 응답 본문(비밀은 아니지만 새면 안 된다)")
client = _client(handler, monkeypatch)
with pytest.raises(SearchConsoleError) as exc:
async with client:
await client.submit_sitemap(PROPERTY, SITEMAP)
assert exc.value.code == code
assert "google 응답 본문" not in str(exc.value)
# ── 4) 전송 오류 ─────────────────────────────────────────────────────────
async def test_타임아웃은_timeout으로_정규화(monkeypatch):
"""검증: 요청이 타임아웃된다.
기대결과: SearchConsoleError("timeout")."""
def handler(request):
raise httpx.ReadTimeout("timed out", request=request)
client = _client(handler, monkeypatch)
with pytest.raises(SearchConsoleError) as exc:
async with client:
await client.inspect_url(PROPERTY, PAGE)
assert exc.value.code == "timeout"
async def test_연결_실패는_transport_error로_정규화(monkeypatch):
"""검증: 연결 자체가 끊긴다(DNS 실패 등).
기대결과: SearchConsoleError("transport_error")."""
def handler(request):
raise httpx.ConnectError("연결 실패", request=request)
client = _client(handler, monkeypatch)
with pytest.raises(SearchConsoleError) as exc:
async with client:
await client.submit_sitemap(PROPERTY, SITEMAP)
assert exc.value.code == "transport_error"
# ── 5) 인증 실패 ─────────────────────────────────────────────────────────
async def test_키파일_로딩_실패는_invalid_credentials_file(monkeypatch):
"""검증: 서비스 계정 키 파일을 못 읽는다(없음·손상).
기대결과: SearchConsoleError("invalid_credentials_file") — 원본 예외 메시지는 안 실린다."""
def handler(request):
raise AssertionError("인증에 실패했으면 네트워크를 타면 안 된다")
client = _client(handler, monkeypatch, load_error=ValueError("키 파일 내용: super-secret-key-material"))
with pytest.raises(SearchConsoleError) as exc:
async with client:
await client.submit_sitemap(PROPERTY, SITEMAP)
assert exc.value.code == "invalid_credentials_file"
assert "super-secret-key-material" not in str(exc.value)
async def test_토큰_갱신_실패는_auth_failed(monkeypatch):
"""검증: 키 파일은 읽었지만 Google 토큰 발급이 거부된다(폐기된 키 등).
기대결과: SearchConsoleError("auth_failed") — 원본 예외 메시지는 안 실린다."""
def handler(request):
raise AssertionError("인증에 실패했으면 네트워크를 타면 안 된다")
client = _client(handler, monkeypatch, refresh_error=RuntimeError("invalid_grant: token revoked=xyz"))
with pytest.raises(SearchConsoleError) as exc:
async with client:
await client.inspect_url(PROPERTY, PAGE)
assert exc.value.code == "auth_failed"
assert "revoked=xyz" not in str(exc.value)
async def test_토큰이_비면_auth_failed(monkeypatch):
"""검증: 갱신은 예외 없이 끝났지만 credentials.token 이 비어 있다.
기대결과: SearchConsoleError("auth_failed") — 빈 토큰으로 요청을 보내지 않는다."""
def handler(request):
raise AssertionError("빈 토큰으로 네트워크를 타면 안 된다")
client = _client(handler, monkeypatch, token="")
with pytest.raises(SearchConsoleError) as exc:
async with client:
await client.submit_sitemap(PROPERTY, SITEMAP)
assert exc.value.code == "auth_failed"
async def test_인증정보_로딩과_유효한_토큰_갱신은_한_번만_한다(monkeypatch):
"""검증: 같은 클라이언트로 두 번 호출한다.
기대결과: 키 파일 로딩 1회 · 토큰 갱신 1회 — 두 번째 호출은 여전히 유효한(`credentials.valid`)
토큰을 그대로 재사용한다."""
def handler(request):
return httpx.Response(200)
state = _auth(monkeypatch)
client = SearchConsoleClient("dummy-credentials.json", transport=httpx.MockTransport(handler))
async with client:
await client.submit_sitemap(PROPERTY, SITEMAP)
await client.submit_sitemap(PROPERTY, SITEMAP)
assert state["loads"] == 1
assert state["refreshes"] == 1
async def test_동시_호출은_토큰_갱신을_한_번만_한다(monkeypatch):
"""검증: 아직 토큰이 없는 클라이언트를 두 요청이 동시에 부른다.
기대결과: 갱신 1회 — 잠금 없이 두 번째 요청이 갱신 중인 자격을 다시 갱신하면
Google 토큰 엔드포인트를 요청 수만큼 때리게 된다."""
import time as _time
def handler(request):
return httpx.Response(200)
state = _auth(monkeypatch)
def slow_refresh(creds):
_time.sleep(0.05)
state["refreshes"] += 1
creds.token = "tok"
creds.valid = True
monkeypatch.setattr(scc, "_refresh_sync", slow_refresh)
client = SearchConsoleClient("dummy-credentials.json", transport=httpx.MockTransport(handler))
async with client:
await asyncio.gather(
client.submit_sitemap(PROPERTY, SITEMAP),
client.submit_sitemap(PROPERTY, SITEMAP),
)
assert state["refreshes"] == 1
# ── 6) 닫기 ──────────────────────────────────────────────────────────────
async def test_async_컨텍스트매니저는_내부_httpx_클라이언트를_닫는다(monkeypatch):
"""검증: `async with` 블록을 빠져나간다.
기대결과: 내부 httpx.AsyncClient 가 닫힌다 — 커넥션을 남겨두지 않는다."""
def handler(request):
return httpx.Response(200)
client = _client(handler, monkeypatch)
async with client:
pass
assert client._client.is_closed is True

View File

@ -0,0 +1,273 @@
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import uuid
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from common.database.model.models import places, sites, site_versions, site_search_status
from crud import search_console_crud as store
from services import search_console_service as service
from services.search_console_client import SearchConsoleError
from services.search_console_alerts import alert_reason, send_alert
from services.search_console_settings import SearchConsoleSettings, belongs_to_property, load_settings
NOW = datetime.now(timezone.utc)
SETTINGS = SearchConsoleSettings("https://example.com/", "/test/key.json")
def status(**changes):
return SimpleNamespace(site_id=uuid.uuid4(), page_url="https://example.com/s/stay",
property_url=SETTINGS.property_url, published_at=NOW - timedelta(days=8),
sitemap_submitted_at=None, inspected_at=None, inspection=None,
first_indexed_at=None, error_code=None, failures=0, alerted_at=None,
next_check_at=NOW, **changes)
@pytest.mark.parametrize("url,prop,expected", [
("https://example.com/s/a", "https://example.com/", True),
("https://example.com.evil.test/s/a", "https://example.com/", False),
("https://sub.example.com/s/a", "sc-domain:example.com", True),
("https://notexample.com/s/a", "sc-domain:example.com", False),
("http://example.com/s/a", "sc-domain:example.com", False),
("https://example.com/s/a?secret=x", "sc-domain:example.com", False),
])
def test_property_scope(url, prop, expected):
assert belongs_to_property(url, prop) is expected
def test_disabled_needs_no_credentials(monkeypatch):
monkeypatch.delenv("GSC_ENABLED", raising=False)
assert load_settings() is None
monkeypatch.setenv("GSC_ENABLED", "1")
monkeypatch.delenv("GSC_PROPERTY_URL", raising=False)
with pytest.raises(ValueError):
load_settings()
async def test_submit_and_inspect_are_separate(monkeypatch):
row = status()
client = SimpleNamespace(submit_sitemap=AsyncMock(), inspect_url=AsyncMock(return_value={"verdict": "NEUTRAL"}))
monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value={row.page_url}))
monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com")
await service.check_site(client, row, SETTINGS, set())
assert row.sitemap_submitted_at
assert row.inspected_at
assert row.first_indexed_at is None
assert row.next_check_at > NOW
assert row.error_code is None
async def test_missing_sitemap_url_retries_without_google_call(monkeypatch):
row = status()
client = SimpleNamespace(submit_sitemap=AsyncMock(), inspect_url=AsyncMock())
monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value=set()))
await service.check_site(client, row, SETTINGS, set())
assert row.error_code == "URL_NOT_IN_SITEMAP"
assert row.sitemap_submitted_at is None
assert row.failures == 1
client.submit_sitemap.assert_not_awaited()
client.inspect_url.assert_not_awaited()
async def test_one_sitemap_submission_per_batch(monkeypatch):
client = SimpleNamespace(submit_sitemap=AsyncMock())
monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value={status().page_url}))
seen = set()
await service.submit_sitemap(client, status(), seen)
await service.submit_sitemap(client, status(), seen)
client.submit_sitemap.assert_awaited_once()
async def test_inspection_failure_preserves_previous_observation():
row = status()
row.sitemap_submitted_at = NOW
row.inspection = {"verdict": "PASS"}
row.inspected_at = NOW - timedelta(days=1)
client = SimpleNamespace(inspect_url=AsyncMock(side_effect=SearchConsoleError("HTTP_429")))
await service.check_site(client, row, SETTINGS, set())
assert row.inspection == {"verdict": "PASS"}
assert row.inspected_at < NOW
assert row.error_code == "HTTP_429"
assert row.failures == 1
def test_first_indexed_is_observation_time_not_google_crawl_time():
row = status()
service.record_inspection(row, {"verdict": "PASS", "lastCrawlTime": "2020-01-01T00:00:00Z"}, NOW)
service.record_inspection(row, {"verdict": "PASS"}, NOW + timedelta(days=1))
assert row.first_indexed_at == NOW
def test_alert_cooldown_and_backoff():
row = status()
assert alert_reason(row, NOW, 7)
row.alerted_at = NOW
assert alert_reason(row, NOW, 7) is None
for _ in range(30):
service.record_error(row, "HTTP_503", NOW)
assert row.next_check_at == NOW + timedelta(days=1)
async def test_failed_alert_does_not_mark_delivered(monkeypatch):
row = status()
row.sitemap_submitted_at = NOW
client = SimpleNamespace(inspect_url=AsyncMock(return_value={"verdict": "NEUTRAL"}))
send = AsyncMock(return_value=False)
monkeypatch.setattr(service, "send_alert", send)
await service.check_site(client, row, SETTINGS, set())
send.assert_awaited_once()
assert row.alerted_at is None
async def test_empty_alert_url_never_calls_network():
assert not await send_alert("", "https://example.com/s/a", "delayed")
async def test_disabled_cron_never_opens_database(monkeypatch):
monkeypatch.delenv("GSC_ENABLED", raising=False)
call = AsyncMock()
monkeypatch.setattr(service.DB_SESSION_MNG, "execute_lambda_write", call)
await service.run_scheduled_check()
call.assert_not_awaited()
async def seed_site(session, *, state=3):
place = places(place_id=uuid.uuid4(), owner_user_id=uuid.uuid4(), name="숙소", category=1)
site = sites(site_id=uuid.uuid4(), place_id=place.place_id, status=state,
domain="stay", published_at=NOW)
version = site_versions(site_version_id=uuid.uuid4(), site_id=site.site_id, version=1, build_status=2)
site.current_version_id = version.site_version_id
session.add_all([place, site, version])
await session.flush()
return site, place, version
async def test_registration_survives_restart_and_resets_on_publish(db_engine, monkeypatch):
monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com")
async with AsyncSession(db_engine, expire_on_commit=False) as session:
site, _, _ = await seed_site(session)
await service.register_publications(session, SETTINGS)
await session.commit()
row = await session.get(site_search_status, site.site_id)
row.sitemap_submitted_at = NOW
row.first_indexed_at = NOW
await session.commit()
await service.register_publications(session, SETTINGS)
assert row.first_indexed_at == NOW
site.published_at = NOW + timedelta(seconds=1)
await session.commit()
await service.register_publications(session, SETTINGS)
await session.commit()
await session.refresh(row)
assert row.first_indexed_at is None
assert row.sitemap_submitted_at is None
async def test_unpublished_and_deleted_places_are_not_checked(db_engine, monkeypatch):
monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com")
async with AsyncSession(db_engine) as session:
site, place, _ = await seed_site(session)
await service.register_publications(session, SETTINGS)
assert len(await store.due_sites(session, SETTINGS.property_url)) == 1
site.status = 5
await session.flush()
assert not await store.due_sites(session, SETTINGS.property_url)
site.status = 3
place.deleted = True
await session.flush()
assert not await store.due_sites(session, SETTINGS.property_url)
async def test_batch_lock_excludes_second_process(db_engine):
async with AsyncSession(db_engine) as first, AsyncSession(db_engine) as second:
assert await store.lock_batch(first)
assert not await store.lock_batch(second)
await first.rollback()
assert await store.lock_batch(second)
async def test_full_batch_commits_and_does_not_repeat_same_day(db_engine, monkeypatch):
monkeypatch.setattr(service.site_payload, "publish_origin", lambda: "https://example.com")
monkeypatch.setattr(service, "read_sitemap", AsyncMock(return_value={status().page_url}))
client = SimpleNamespace(submit_sitemap=AsyncMock(), inspect_url=AsyncMock(return_value={"verdict": "PASS"}))
class FakeClient:
def __init__(self, *_):
pass
async def __aenter__(self):
return client
async def __aexit__(self, *_):
pass
monkeypatch.setattr(service, "SearchConsoleClient", FakeClient)
async with AsyncSession(db_engine) as session:
site, _, _ = await seed_site(session)
sid = site.site_id
await session.commit()
await service.run_batch(session, SETTINGS)
await session.commit()
async with AsyncSession(db_engine) as session:
row = await session.get(site_search_status, sid)
assert row.sitemap_submitted_at
assert row.first_indexed_at
await service.run_batch(session, SETTINGS)
await session.commit()
client.submit_sitemap.assert_awaited_once()
client.inspect_url.assert_awaited_once()
async def test_cron_failure_is_isolated(monkeypatch):
monkeypatch.setattr(service, "load_settings", lambda: SETTINGS)
monkeypatch.setattr(service.DB_SESSION_MNG, "execute_lambda_write", AsyncMock(side_effect=RuntimeError("db")))
await service.run_scheduled_check()
async def test_sitemap_http_parser(monkeypatch):
import httpx
real_client = httpx.AsyncClient
transport = httpx.MockTransport(lambda request: httpx.Response(200, text='''
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://example.com/s/stay</loc></url></urlset>'''))
monkeypatch.setattr(service.httpx, "AsyncClient", lambda **kw: real_client(transport=transport, **kw))
assert await service.read_sitemap("https://example.com/sitemap.xml") == {status().page_url}
async def test_sitemap_redirect_is_not_followed(monkeypatch):
import httpx
real_client = httpx.AsyncClient
transport = httpx.MockTransport(lambda request: httpx.Response(302, headers={"location": "http://internal/"}))
monkeypatch.setattr(service.httpx, "AsyncClient", lambda **kw: real_client(transport=transport, **kw))
with pytest.raises(SearchConsoleError, match="SITEMAP_UNAVAILABLE"):
await service.read_sitemap("https://example.com/sitemap.xml")
@pytest.mark.parametrize("enabled,count", [("0", 0), ("1", 1)])
def test_existing_scheduler_registers_optional_job(monkeypatch, enabled, count):
import scheduler
instance = SimpleNamespace(add_job=lambda *a, **kw: jobs.append((a, kw)),
start=lambda: None, get_jobs=lambda: jobs)
jobs = []
monkeypatch.setattr(scheduler, "_scheduler", None)
monkeypatch.setattr(scheduler, "AsyncIOScheduler", lambda **kw: instance)
monkeypatch.setenv("SCHEDULER_ENABLED", "1")
monkeypatch.setenv("GSC_ENABLED", enabled)
scheduler.start_scheduler()
assert len(jobs) == count
if jobs:
assert jobs[0][1]["minutes"] == 10
assert jobs[0][1]["max_instances"] == 1
def test_migration_matches_fresh_database_schema():
from pathlib import Path
root = Path(__file__).resolve().parents[3]
migration = (root / "postgres-init/migrations/0014_search_console.sql").read_text()
ddl = migration[migration.index("CREATE TABLE"):].strip()
assert ddl in (root / "postgres-init/init-data/init.sql").read_text()