feat(ssulbox): 공식 링크 오버레이 추가 및 AI 브리핑 대기 제거
This commit is contained in:
parent
934410a83b
commit
6d46614dcd
@ -83,7 +83,6 @@ async def create_ssul(
|
|||||||
session,
|
session,
|
||||||
user_uuid=current_user.user_uuid,
|
user_uuid=current_user.user_uuid,
|
||||||
scenario=body.scenario,
|
scenario=body.scenario,
|
||||||
input_text=body.input,
|
|
||||||
scenes=body.scenes,
|
scenes=body.scenes,
|
||||||
seconds=body.seconds,
|
seconds=body.seconds,
|
||||||
store_name=body.store_name,
|
store_name=body.store_name,
|
||||||
@ -300,6 +299,7 @@ async def get_content_detail(
|
|||||||
description=row.description,
|
description=row.description,
|
||||||
store_name=row.store_name or None,
|
store_name=row.store_name or None,
|
||||||
region=row.region,
|
region=row.region,
|
||||||
|
official_site_url=row.official_site_url,
|
||||||
created_at=row.created_at,
|
created_at=row.created_at,
|
||||||
like_count=like_count,
|
like_count=like_count,
|
||||||
is_liked_by_me=is_liked,
|
is_liked_by_me=is_liked,
|
||||||
|
|||||||
@ -110,10 +110,14 @@ class SsulContent(Base):
|
|||||||
comment="시나리오 코드 (joseon/samgukji/greek/odyssey)",
|
comment="시나리오 코드 (joseon/samgukji/greek/odyssey)",
|
||||||
)
|
)
|
||||||
|
|
||||||
input: Mapped[str] = mapped_column(
|
# castad `marketing.official_site_url` 과 같은 의미다 — 영상 종료 직전 오버레이의 링크.
|
||||||
Text,
|
# 생성 요청 시점에는 알 수 없어 NULL 로 시작하고, 워커가 place 페이지를 크롤링할 때
|
||||||
nullable=False,
|
# 채운다(홈페이지 항목 우선, 없으면 네이버 플레이스 URL). 업장명만 입력해 place URL
|
||||||
comment="입력값 (네이버 지도 URL 또는 업장명)",
|
# 해석까지 실패하면 끝까지 NULL 이고, 그때는 프론트가 오버레이를 그리지 않는다.
|
||||||
|
official_site_url: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(2048),
|
||||||
|
nullable=True,
|
||||||
|
comment="업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 네이버 플레이스 URL; 미확보 시 NULL)",
|
||||||
)
|
)
|
||||||
|
|
||||||
# scenes / seconds 는 DB 기본값을 두지 않는다. 기본값(9 / 30)과 허용 범위
|
# scenes / seconds 는 DB 기본값을 두지 않는다. 기본값(9 / 30)과 허용 범위
|
||||||
|
|||||||
@ -119,6 +119,13 @@ class SsulDetailResponse(BaseModel):
|
|||||||
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
description: Optional[str] = Field(None, description="SNS 업로드 설명")
|
||||||
store_name: Optional[str] = Field(None, description="업장명")
|
store_name: Optional[str] = Field(None, description="업장명")
|
||||||
region: Optional[str] = Field(None, description="지역명")
|
region: Optional[str] = Field(None, description="지역명")
|
||||||
|
official_site_url: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description=(
|
||||||
|
"업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 네이버 플레이스 URL). "
|
||||||
|
"영상 종료 직전 오버레이에 쓰며, 미확보 시 null 이라 오버레이를 그리지 않는다"
|
||||||
|
),
|
||||||
|
)
|
||||||
created_at: datetime = Field(..., description="생성 일시")
|
created_at: datetime = Field(..., description="생성 일시")
|
||||||
like_count: int = Field(..., description="좋아요 수")
|
like_count: int = Field(..., description="좋아요 수")
|
||||||
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지")
|
is_liked_by_me: bool = Field(..., description="현재 로그인 사용자가 좋아요를 눌렀는지")
|
||||||
|
|||||||
@ -141,6 +141,10 @@ async def _extract_detail_from_page(page, tries: int = 12) -> dict | None:
|
|||||||
continue
|
continue
|
||||||
detail = _detail_from_state(state)
|
detail = _detail_from_state(state)
|
||||||
if detail:
|
if detail:
|
||||||
|
# 공식 링크는 apollo state 의 파싱된 dict 가 아니라 원문 HTML 에서 뽑는다
|
||||||
|
# (`homepages` 는 GraphQL placeDetail 이 노출하지 않아 castad 도 같은 방식).
|
||||||
|
# 이미 받아 둔 html 을 재사용하므로 추가 요청·왕복이 없다.
|
||||||
|
detail["homepage"] = NvMapScraper._extract_homepage_from_html(html)
|
||||||
return detail
|
return detail
|
||||||
await page.wait_for_timeout(700)
|
await page.wait_for_timeout(700)
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -42,7 +42,6 @@ async def create_task(
|
|||||||
*,
|
*,
|
||||||
user_uuid: str,
|
user_uuid: str,
|
||||||
scenario: str,
|
scenario: str,
|
||||||
input_text: str,
|
|
||||||
scenes: int,
|
scenes: int,
|
||||||
seconds: int,
|
seconds: int,
|
||||||
store_name: Optional[str] = None,
|
store_name: Optional[str] = None,
|
||||||
@ -65,7 +64,6 @@ async def create_task(
|
|||||||
row = SsulContent(
|
row = SsulContent(
|
||||||
user_uuid=user_uuid,
|
user_uuid=user_uuid,
|
||||||
scenario=scenario,
|
scenario=scenario,
|
||||||
input=input_text,
|
|
||||||
scenes=scenes,
|
scenes=scenes,
|
||||||
seconds=seconds,
|
seconds=seconds,
|
||||||
status=SsulTaskStatus.QUEUED.value,
|
status=SsulTaskStatus.QUEUED.value,
|
||||||
@ -217,15 +215,15 @@ async def fail_task(
|
|||||||
|
|
||||||
async def get_place_info(
|
async def get_place_info(
|
||||||
session: AsyncSession, content_id: int
|
session: AsyncSession, content_id: int
|
||||||
) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
|
||||||
"""(store_name, region, detail_region_info).
|
"""(store_name, region, detail_region_info, official_site_url).
|
||||||
|
|
||||||
크롤링으로 채울 값이 남았는지 판단하는 데 쓴다.
|
크롤링으로 채울 값이 남았는지 판단하는 데 쓴다.
|
||||||
"""
|
"""
|
||||||
row = await session.get(SsulContent, content_id)
|
row = await session.get(SsulContent, content_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
return None, None, None
|
return None, None, None, None
|
||||||
return row.store_name, row.region, row.detail_region_info
|
return row.store_name, row.region, row.detail_region_info, row.official_site_url
|
||||||
|
|
||||||
|
|
||||||
async def set_place_info(
|
async def set_place_info(
|
||||||
@ -235,10 +233,14 @@ async def set_place_info(
|
|||||||
store_name: Optional[str] = None,
|
store_name: Optional[str] = None,
|
||||||
region: Optional[str] = None,
|
region: Optional[str] = None,
|
||||||
detail_region_info: Optional[str] = None,
|
detail_region_info: Optional[str] = None,
|
||||||
|
official_site_url: Optional[str] = None,
|
||||||
) -> Optional[SsulContent]:
|
) -> Optional[SsulContent]:
|
||||||
"""크롤링으로 얻은 업장 정보를 채운다. **이미 있는 값은 덮지 않는다.**
|
"""크롤링으로 얻은 업장 정보를 채운다. **이미 있는 값은 덮지 않는다.**
|
||||||
|
|
||||||
사용자가 검색으로 직접 고른 값이 크롤링 추정치보다 정확하므로 우선한다.
|
사용자가 검색으로 직접 고른 값이 크롤링 추정치보다 정확하므로 우선한다.
|
||||||
|
|
||||||
|
`official_site_url` 만 예외로 덮어쓴다 — 호출부가 플레이스 URL 을 먼저 폴백으로
|
||||||
|
넣어 두고 크롤링에 성공하면 진짜 홈페이지로 승급시키기 때문이다.
|
||||||
"""
|
"""
|
||||||
row = await session.get(SsulContent, content_id)
|
row = await session.get(SsulContent, content_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
@ -249,9 +251,12 @@ async def set_place_info(
|
|||||||
row.region = region
|
row.region = region
|
||||||
if detail_region_info and not row.detail_region_info:
|
if detail_region_info and not row.detail_region_info:
|
||||||
row.detail_region_info = detail_region_info
|
row.detail_region_info = detail_region_info
|
||||||
|
if official_site_url:
|
||||||
|
row.official_site_url = official_site_url[:2048]
|
||||||
logger.info(
|
logger.info(
|
||||||
f"[set_place_info] id={content_id} store={row.store_name!r} "
|
f"[set_place_info] id={content_id} store={row.store_name!r} "
|
||||||
f"region={row.region!r} detail={(row.detail_region_info or '')[:30]!r}"
|
f"region={row.region!r} detail={(row.detail_region_info or '')[:30]!r} "
|
||||||
|
f"site={(row.official_site_url or '')[:60]!r}"
|
||||||
)
|
)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|||||||
@ -144,14 +144,18 @@ async def _update_step(content_id: int, step: int) -> None:
|
|||||||
|
|
||||||
async def _get_place_info(
|
async def _get_place_info(
|
||||||
content_id: int,
|
content_id: int,
|
||||||
) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
) -> tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
|
||||||
"""현재 저장된 (store_name, region, detail_region_info)."""
|
"""현재 저장된 (store_name, region, detail_region_info, official_site_url)."""
|
||||||
async with BackgroundSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
return await task_service.get_place_info(session, content_id)
|
return await task_service.get_place_info(session, content_id)
|
||||||
|
|
||||||
|
|
||||||
async def _save_place(
|
async def _save_place(
|
||||||
content_id: int, store_name: str, region: str, detail: str
|
content_id: int,
|
||||||
|
store_name: str,
|
||||||
|
region: str,
|
||||||
|
detail: str,
|
||||||
|
official_site_url: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
async with BackgroundSessionLocal() as session:
|
async with BackgroundSessionLocal() as session:
|
||||||
await task_service.set_place_info(
|
await task_service.set_place_info(
|
||||||
@ -160,6 +164,7 @@ async def _save_place(
|
|||||||
store_name=store_name,
|
store_name=store_name,
|
||||||
region=region,
|
region=region,
|
||||||
detail_region_info=detail,
|
detail_region_info=detail,
|
||||||
|
official_site_url=official_site_url,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@ -249,23 +254,29 @@ def _is_place_url(text: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _collect_place_info(content_id: int, job: dict) -> None:
|
def _collect_place_info(content_id: int, job: dict) -> None:
|
||||||
"""비어 있는 업장명·지역을 크롤링으로 채운다(있는 값은 유지).
|
"""비어 있는 업장명·지역·공식 링크를 크롤링으로 채운다(있는 값은 유지).
|
||||||
|
|
||||||
**빠진 값이 있을 때만 크롤링한다.** 검색으로 고른 경우 create 시점에 둘 다
|
**빠진 값이 있을 때만 크롤링한다.** 검색으로 고른 경우 create 시점에 업장명·지역이
|
||||||
채워져 있으므로 브라우저를 띄우지 않는다. 다만 검색 경로에서도 지역만 비는
|
채워져 있으나 공식 링크는 늘 비어 있으므로, 그 경로에서도 이 함수가 크롤링한다
|
||||||
경우(주소에서 시/군을 못 뽑는 등)가 있어, 존재 여부를 실제로 확인하고 판단한다.
|
(Playwright 1회, 최대 120초). 링크를 못 얻어도 place URL 폴백은 남는다.
|
||||||
|
|
||||||
수집 실패는 삼킨다. 이 정보가 없어도 생성은 place_url 만으로 진행된다.
|
수집 실패는 삼킨다. 이 정보가 없어도 생성은 place_url 만으로 진행된다.
|
||||||
"""
|
"""
|
||||||
if not _is_place_url(job.get("input", "")):
|
place_url = job.get("input", "")
|
||||||
return
|
if not _is_place_url(place_url):
|
||||||
|
return # 업장명 해석 실패 — 링크로 쓸 값이 없다
|
||||||
try:
|
try:
|
||||||
store_name, region, detail = _run_db(_get_place_info(content_id))
|
store_name, region, detail, site_url = _run_db(_get_place_info(content_id))
|
||||||
if store_name and region and detail:
|
if store_name and region and detail and site_url:
|
||||||
return # 채울 것이 없다
|
return # 채울 것이 없다
|
||||||
|
|
||||||
|
# 크롤링이 실패해도 오버레이가 뜨도록 place URL 을 먼저 폴백으로 저장한다.
|
||||||
|
# castad 가 `official_site_url or 크롤링 소스 URL` 로 폴백하는 것과 같은 규칙.
|
||||||
|
if not site_url:
|
||||||
|
_run_db(_save_place(content_id, "", "", "", place_url))
|
||||||
|
|
||||||
detail = _run_db(
|
detail = _run_db(
|
||||||
place_service.fetch_place_detail(job["input"]),
|
place_service.fetch_place_detail(place_url),
|
||||||
# Playwright 기동 + 상세 파싱까지 DB 위임 기본 타임아웃(60s)보다 길 수 있다
|
# Playwright 기동 + 상세 파싱까지 DB 위임 기본 타임아웃(60s)보다 길 수 있다
|
||||||
timeout=120,
|
timeout=120,
|
||||||
)
|
)
|
||||||
@ -275,14 +286,16 @@ def _collect_place_info(content_id: int, job: dict) -> None:
|
|||||||
title = detail.get("title") or ""
|
title = detail.get("title") or ""
|
||||||
road = detail.get("roadAddress") or ""
|
road = detail.get("roadAddress") or ""
|
||||||
jibun = detail.get("address") or ""
|
jibun = detail.get("address") or ""
|
||||||
|
homepage = detail.get("homepage") or ""
|
||||||
# castad `/home/crawl` 과 동일하게 **도로명·지번을 모두** 넘긴다.
|
# castad `/home/crawl` 과 동일하게 **도로명·지번을 모두** 넘긴다.
|
||||||
# 도로명에서 시/군 추출이 실패하면 지번으로 재시도한다(한쪽만 넘기면 놓친다).
|
# 도로명에서 시/군 추출이 실패하면 지번으로 재시도한다(한쪽만 넘기면 놓친다).
|
||||||
new_region = extract_region_from_address(road or None, jibun or None)
|
new_region = extract_region_from_address(road or None, jibun or None)
|
||||||
new_detail = road or jibun # 도로명 우선, 없으면 지번
|
new_detail = road or jibun # 도로명 우선, 없으면 지번
|
||||||
if title:
|
if title:
|
||||||
job["store_name"] = title
|
job["store_name"] = title
|
||||||
if title or new_region or new_detail:
|
if title or new_region or new_detail or homepage:
|
||||||
_run_db(_save_place(content_id, title, new_region, new_detail))
|
# homepage 가 있으면 위에서 넣어 둔 place URL 폴백을 진짜 홈페이지로 승급시킨다.
|
||||||
|
_run_db(_save_place(content_id, title, new_region, new_detail, homepage))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
f"[ssul {content_id}] 업장 정보 수집 실패(생성은 계속): "
|
f"[ssul {content_id}] 업장 정보 수집 실패(생성은 계속): "
|
||||||
|
|||||||
@ -0,0 +1,21 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Migration: ssul_content.input → official_site_url 로 전환
|
||||||
|
-- Date: 2026-08-24
|
||||||
|
-- Description: 썰박스 상세에도 ADO2 영상과 동일한 '공식 링크 오버레이'를 적용한다.
|
||||||
|
-- `input`(요청 원본: place URL 또는 업장명)은 INSERT 후 어디서도
|
||||||
|
-- 읽지 않는 쓰기 전용 컬럼이었다 — 워커는 인메모리 job dict 를 쓴다.
|
||||||
|
-- 그 자리를 공식 링크(플레이스 홈페이지 우선, 없으면 플레이스 URL)로 돌린다.
|
||||||
|
-- 관련 코드: app/ssulbox/worker/job_manager.py(_collect_place_info),
|
||||||
|
-- app/ssulbox/services/place_service.py(_extract_detail_from_page)
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
ALTER TABLE `ssul_content`
|
||||||
|
CHANGE COLUMN `input` `official_site_url` VARCHAR(2048) NULL
|
||||||
|
COMMENT '업체 공식 링크 (플레이스 홈페이지 항목 우선, 없으면 네이버 플레이스 URL; 미확보 시 NULL)';
|
||||||
|
|
||||||
|
-- ⚠️ 반드시 함께 실행할 것.
|
||||||
|
-- CHANGE 는 기존 `input` 값을 새 컬럼으로 그대로 옮긴다. 업장명만 입력해 만든 행은
|
||||||
|
-- URL 이 아닌 문자열("스테이 머뭄" 등)을 갖게 되고, 그대로 두면 상세 화면의 링크
|
||||||
|
-- href 에 그 값이 들어간다. 기존 콘텐츠는 오버레이 대상이 아니므로(신규 생성분부터
|
||||||
|
-- 적용) 전부 비운다.
|
||||||
|
UPDATE `ssul_content` SET `official_site_url` = NULL;
|
||||||
@ -339,6 +339,11 @@ def _parse_ai_briefing(txt):
|
|||||||
|
|
||||||
|
|
||||||
AI_BRIEFING_TIMEOUT = 18 # getAiBriefing 응답 대기(초). 정상은 ~6s, 이 안에 안 오면 스로틀/지연으로 본다.
|
AI_BRIEFING_TIMEOUT = 18 # getAiBriefing 응답 대기(초). 정상은 ~6s, 이 안에 안 오면 스로틀/지연으로 본다.
|
||||||
|
# place 홈 진입 후 getAiBriefing '요청' 발사를 기다리는 창(초).
|
||||||
|
# 브리핑이 없는 업체엔 네이버가 요청 자체를 만들지 않는다 — 실측(2026-08-24, zzz/probe_ai_briefing.py):
|
||||||
|
# 브리핑 있는 업체는 진입 0.8초 뒤 발사·1.1초에 응답, 없는 업체는 45초를 봐도 0회.
|
||||||
|
# 이 창을 넘기면 '없음'이 확정이므로 응답을 기다릴 이유도, 새 세션으로 재시도할 이유도 없다.
|
||||||
|
AI_BRIEFING_REQUEST_WINDOW = 6
|
||||||
AI_BRIEFING_RETRIES = 2 # 응답이 끝내 안 올 때(kind='timeout') 새 세션으로 재시도할 횟수.
|
AI_BRIEFING_RETRIES = 2 # 응답이 끝내 안 올 때(kind='timeout') 새 세션으로 재시도할 횟수.
|
||||||
AI_BRIEFING_RETRY_DELAY = 4 # 재시도 전 대기(초) — 연속 타격으로 스로틀이 굳는 걸 늦춘다.
|
AI_BRIEFING_RETRY_DELAY = 4 # 재시도 전 대기(초) — 연속 타격으로 스로틀이 굳는 걸 늦춘다.
|
||||||
|
|
||||||
@ -348,8 +353,8 @@ async def _fetch(url, headful=False):
|
|||||||
끝내 안 오면(kind='timeout', 스로틀/지연 추정) 새 세션으로 최대 AI_BRIEFING_RETRIES 회 재시도한다.
|
끝내 안 오면(kind='timeout', 스로틀/지연 추정) 새 세션으로 최대 AI_BRIEFING_RETRIES 회 재시도한다.
|
||||||
반환은 공개 API 형식인 3-튜플 (store, bullets, cat) — 내부 kind 는 여기서 소비.
|
반환은 공개 API 형식인 3-튜플 (store, bullets, cat) — 내부 kind 는 여기서 소비.
|
||||||
kind='briefing' → 브리핑 확보(bullets 채움)
|
kind='briefing' → 브리핑 확보(bullets 채움)
|
||||||
kind='none' → 브리핑 없는 업체 확정(bullets=None → 호출부가 리뷰 폴백)
|
kind='none' → 브리핑 없는 업체 확정(요청 미발사 또는 빈 응답; bullets=None → 호출부가 리뷰 폴백)
|
||||||
kind='timeout' → 응답 보류(재시도 가치 있음); 소진 시 bullets=None(→ 리뷰 폴백)."""
|
kind='timeout' → 요청은 나갔으나 응답 보류(재시도 가치 있음); 소진 시 bullets=None(→ 리뷰 폴백)."""
|
||||||
last_store, last_cat = None, None
|
last_store, last_cat = None, None
|
||||||
for i in range(AI_BRIEFING_RETRIES + 1):
|
for i in range(AI_BRIEFING_RETRIES + 1):
|
||||||
store, bullets, cat, kind = await _with_profile_retry(
|
store, bullets, cat, kind = await _with_profile_retry(
|
||||||
@ -369,15 +374,16 @@ async def _fetch_once(url, profile, headful=False):
|
|||||||
"""네이버 플레이스 진입 → getAiBriefing GraphQL 응답을 가로채 브리핑을 파싱.
|
"""네이버 플레이스 진입 → getAiBriefing GraphQL 응답을 가로채 브리핑을 파싱.
|
||||||
반환 4-튜플 (store, bullets, cat, kind):
|
반환 4-튜플 (store, bullets, cat, kind):
|
||||||
kind='briefing' → bullets=[불릿...] (브리핑 존재)
|
kind='briefing' → bullets=[불릿...] (브리핑 존재)
|
||||||
kind='none' → bullets=None (aiBriefing null/빈 = 브리핑 없는 업체)
|
kind='none' → bullets=None (요청 미발사 또는 aiBriefing null/빈 = 브리핑 없는 업체)
|
||||||
kind='timeout' → bullets=None (응답이 AI_BRIEFING_TIMEOUT 내 안 옴 = 스로틀/지연)."""
|
kind='timeout' → bullets=None (요청은 나갔으나 응답이 AI_BRIEFING_TIMEOUT 내 안 옴 = 스로틀/지연)."""
|
||||||
async with async_playwright() as p:
|
async with async_playwright() as p:
|
||||||
browser, ctx, page = await _new_page(p, headful=headful, profile=profile)
|
browser, ctx, page = await _new_page(p, headful=headful, profile=profile)
|
||||||
|
|
||||||
# getAiBriefing 응답을 진입 전에 미리 리스닝(진입 직후 발사되므로 놓치지 않게).
|
# getAiBriefing 요청/응답을 진입 전에 미리 리스닝(진입 직후 발사되므로 놓치지 않게).
|
||||||
# parsed: [불릿...] 또는 [](없음 확정) 이면 done, None(무관/파싱실패)이면 계속 대기.
|
# parsed: [불릿...] 또는 [](없음 확정) 이면 done, None(무관/파싱실패)이면 계속 대기.
|
||||||
captured = {"bullets": None}
|
captured = {"bullets": None}
|
||||||
done = asyncio.Event()
|
done = asyncio.Event()
|
||||||
|
requested = asyncio.Event() # getAiBriefing 요청이 실제로 나갔는가
|
||||||
|
|
||||||
async def on_response(resp):
|
async def on_response(resp):
|
||||||
if done.is_set() or not _is_ai_briefing_response(resp):
|
if done.is_set() or not _is_ai_briefing_response(resp):
|
||||||
@ -392,6 +398,13 @@ async def _fetch_once(url, profile, headful=False):
|
|||||||
captured["bullets"] = parsed # [불릿...] 또는 [](없음) → 확정
|
captured["bullets"] = parsed # [불릿...] 또는 [](없음) → 확정
|
||||||
done.set()
|
done.set()
|
||||||
|
|
||||||
|
def on_request(req):
|
||||||
|
if requested.is_set() or "graphql" not in req.url or req.method != "POST":
|
||||||
|
return
|
||||||
|
if "getAiBriefing" in (req.post_data or ""):
|
||||||
|
requested.set()
|
||||||
|
|
||||||
|
page.on("request", on_request)
|
||||||
page.on("response", lambda r: asyncio.ensure_future(on_response(r)))
|
page.on("response", lambda r: asyncio.ensure_future(on_response(r)))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -412,7 +425,15 @@ async def _fetch_once(url, profile, headful=False):
|
|||||||
await browser.close()
|
await browser.close()
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# getAiBriefing 응답을 기다린다(스크롤 불필요 — 진입 직후 XHR 로 발사됨).
|
# 요청이 창 안에 안 나가면 브리핑 미제공 업체로 확정한다. 예전에는 이 경우도
|
||||||
|
# 'timeout' 으로 보고 새 세션 재시도를 2회 돌아 업체당 55~65초를 버렸다.
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(requested.wait(), timeout=AI_BRIEFING_REQUEST_WINDOW)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await browser.close()
|
||||||
|
return store, None, cat, "none"
|
||||||
|
|
||||||
|
# 요청은 나갔다 → 응답을 기다린다(스크롤 불필요 — 진입 직후 XHR 로 발사됨).
|
||||||
got = True
|
got = True
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(done.wait(), timeout=AI_BRIEFING_TIMEOUT)
|
await asyncio.wait_for(done.wait(), timeout=AI_BRIEFING_TIMEOUT)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user