틱톡·영문 인스타/페북 채널 수집 추가

- apify: 틱톡 프로필 액터
- mock_urls.py: 클리닉별 채널 URL 매핑 (mockUrls.json → 파이썬 모듈)
- api/analysis: homepage 매칭으로 미지원 채널 보충 (추후 DB)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
insta-data
Mina Choi 2026-05-27 13:27:39 +09:00
parent cb798f7acc
commit 9817b53be1
4 changed files with 215 additions and 1 deletions

View File

@ -8,10 +8,28 @@ from models.file import FileListItem, FileType, FileUploadResponse
from models.status import AnalysisStatus
from services.pipeline import run_pipeline
from services.file import get_analysis_files_response, handle_analysis_file_upload, soft_delete_analysis_file
from mock_urls import MOCK_CLINICS
from common.utils import _normalize_homepage, _with_scheme
router = APIRouter(prefix="/api/analysis", tags=["analysis"], dependencies=[Depends(verify_api_key)])
logger = logging.getLogger(__name__)
# 추후 DB에 클리닉별로 매핑할 채널(틱톡/영문 인스타·페북). 지금은 mock_urls에서 homepage 매칭으로 보충.
def _extra_channels_from_mockurls(homepage_url: str) -> dict:
"""homepage로 mock_urls에서 클리닉을 찾아 틱톡/영문 인스타·페북 URL 반환 (없으면 {})."""
target = _normalize_homepage(homepage_url)
if not target:
return {}
for c in MOCK_CLINICS:
urls = c["urls"]
if _normalize_homepage(urls.get("homepage", "")) == target:
return {
"tiktok": _with_scheme(urls.get("tiktok")),
"instagram_en": _with_scheme(urls.get("instagramEn")),
"facebook_en": _with_scheme(urls.get("facebookEn")),
}
return {}
@router.post("", status_code=status.HTTP_202_ACCEPTED, response_model=AnalysisStartResponse)
async def start_analysis(body: AnalysisCreate, background_tasks: BackgroundTasks):
@ -38,7 +56,15 @@ async def start_analysis(body: AnalysisCreate, background_tasks: BackgroundTasks
ig_id, fb_id, nb_id, yt_id, gu_id,
)
background_tasks.add_task(run_pipeline, analysis_run_id)
# 클라 값 우선, 없으면 보충 (추후 DB에서 클리닉별로 가져올 값)
mock_extra = _extra_channels_from_mockurls(hospital["url"])
extra_channels = {
"tiktok": body.channels.tiktok or mock_extra.get("tiktok"),
"instagram_en": body.channels.instagram_en or mock_extra.get("instagram_en"),
"facebook_en": body.channels.facebook_en or mock_extra.get("facebook_en"),
}
logger.info("[analysis] extra_channels=%s (mock_matched=%s)", extra_channels, bool(mock_extra))
background_tasks.add_task(run_pipeline, analysis_run_id, extra_channels)
return AnalysisStartResponse(
analysis_run_id=analysis_run_id,

View File

@ -44,6 +44,7 @@ class ApifyClient:
return None
return {
"username": profile["username"],
"profileImage": profile.get("profilePicUrlHD") or profile.get("profilePicUrl"),
"followers": profile.get("followersCount", 0),
"following": profile.get("followsCount", 0),
"posts": profile.get("postsCount", 0),
@ -134,6 +135,7 @@ class ApifyClient:
return None
return {
"pageName": page.get("title") or page.get("name"),
"profileImage": page.get("profilePictureUrl") or page.get("profilePhoto") or page.get("profilePic"),
"pageUrl": page.get("pageUrl", page_url),
"followers": page.get("followers", 0),
"likes": page.get("likes", 0),
@ -145,3 +147,45 @@ class ApifyClient:
"intro": page.get("intro"),
"rating": page.get("rating"),
}
async def fetch_tiktok_profile(self, url: str) -> list[dict]:
user = urlparse(url).path.strip("/").lstrip("@").split("/")[0] if "://" in url else url.lstrip("@")
return await self._run_actor("clockworks~tiktok-scraper", {
"profiles": [user],
"resultsPerPage": 10,
"profileScrapeSections": ["videos"],
"profileSorting": "latest",
"shouldDownloadVideos": False,
"shouldDownloadCovers": False,
"shouldDownloadSubtitles": False,
})
async def get_tiktok_profile(self, url: str) -> dict | None:
items = await self.fetch_tiktok_profile(url)
if not items:
return None
author = (items[0] or {}).get("authorMeta") or {}
videos = [
{
"title": (v.get("text") or "")[:300],
"playCount": v.get("playCount", 0),
"diggCount": v.get("diggCount", 0),
"commentCount": v.get("commentCount", 0),
"shareCount": v.get("shareCount", 0),
"createTime": v.get("createTimeISO"),
"url": v.get("webVideoUrl"),
}
for v in items if isinstance(v, dict)
]
return {
"handle": author.get("name"),
"profileImage": author.get("avatar"),
"nickname": author.get("nickName"),
"followers": author.get("fans", 0),
"following": author.get("following", 0),
"likes": author.get("heart", 0),
"videoCount": author.get("video", 0),
"verified": author.get("verified", False),
"bio": author.get("signature", ""),
"recentVideos": videos[:10],
}

141
app/mock_urls.py Normal file
View File

@ -0,0 +1,141 @@
# 프론트가 아직 안 보내는 채널(틱톡/영문 인스타·페북)을 homepage로 매칭해 보충하는 임시 mock 데이터.
# 기존 mockUrls.json을 파이썬 모듈로 전환 — 런타임 파일 I/O 없이 직접 import.
MOCK_CLINICS = [
{
"label": "뷰성형외과",
"urls": {
"homepage": "viewclinic.com",
"youtube": "youtube.com/channel/UCQqqH3Klj2HQSHNNSVug-CQ",
"instagram": "instagram.com/viewplastic",
"facebook": "facebook.com/viewps1",
"naverPlace": "https://naver.me/x9BxGXkK",
"naverBlog": "blog.naver.com/viewclinicps",
"gangnamUnni": "gangnamunni.com/hospitals/189",
"tiktok": "tiktok.com/@viewplastic",
"tiktokEn": "tiktok.com/@viewplasticsurgery",
"instagramEn": "instagram.com/view_plastic_surgery",
"facebookEn": "facebook.com/viewclinic"
}
},
{
"label": "바노바기 성형외과",
"urls": {
"homepage": "banobagi.com",
"youtube": "youtube.com/c/banobagips",
"instagram": "instagram.com/banobagi_ps",
"facebook": "facebook.com/BanobagiPlasticSurgery",
"naverPlace": "https://naver.me/xxY2yLr5",
"naverBlog": "blog.naver.com/banobagiprs",
"gangnamUnni": "gangnamunni.com/hospitals/23",
"tiktok": "",
"instagramEn": "instagram.com/english_banobagi",
"facebookEn": "facebook.com/englishbanobagi"
}
},
{
"label": "ID 성형외과",
"urls": {
"homepage": "idhospital.com",
"youtube": "youtube.com/user/IDhospital",
"instagram": "instagram.com/idhospital",
"facebook": "facebook.com/idhospital0050",
"naverPlace": "https://naver.me/GtURpCEn",
"naverBlog": "",
"gangnamUnni": "gangnamunni.com/hospitals/257",
"tiktok": "tiktok.com/@idhospitalkorea",
"instagramEn": "instagram.com/idhospitalkorea",
"facebookEn": "facebook.com/idhospital.eng"
}
},
{
"label": "JK 성형외과",
"urls": {
"homepage": "jkplastic.com",
"youtube": "youtube.com/channel/UC5F8dEt32hdp3cTeFyls4qg",
"instagram": "instagram.com/jkplasticsurgery_kr",
"facebook": "facebook.com/jkmedicalgroup",
"naverPlace": "https://naver.me/x67y6cAc",
"naverBlog": "blog.naver.com/jkstory1",
"gangnamUnni": "gangnamunni.com/hospitals/858",
"tiktok": "tiktok.com/@jkplastic",
"instagramEn": "instagram.com/jkplasticsurgery",
"facebookEn": "facebook.com/jkplastic"
}
},
{
"label": "그랜드 성형외과",
"urls": {
"homepage": "grandsurgery.com",
"youtube": "youtube.com/channel/UCU2o_aHqsNFuqwtdzVM3xbQ",
"instagram": "instagram.com/grand_korea",
"facebook": "facebook.com/grandps.korea",
"naverPlace": "https://naver.me/Fw7MYKWK",
"naverBlog": "blog.naver.com/grandprs",
"gangnamUnni": "gangnamunni.com/hospitals/62",
"tiktok": "",
"instagramEn": "instagram.com/grandps_eng",
"facebookEn": "facebook.com/grandplasticsurgery"
}
},
{
"label": "BK 성형외과",
"urls": {
"homepage": "bkhospital.com",
"youtube": "youtube.com/channel/UChJONft3hemy5DGbXUveTFg",
"instagram": "instagram.com/bkhospital_korea",
"facebook": "",
"naverPlace": "https://naver.me/517CTH3W",
"naverBlog": "",
"gangnamUnni": "",
"tiktok": "",
"instagramEn": "instagram.com/english_bkhospital",
"facebookEn": "facebook.com/BKPSKoreaE"
}
},
{
"label": "톡스앤필",
"urls": {
"homepage": "toxnfill.com",
"youtube": "youtube.com/channel/UCFpFZkm7mclD-z_-j7FTUag",
"instagram": "instagram.com/toxnfill_official",
"facebook": "facebook.com/toxnfill.official",
"naverPlace": "https://naver.me/FvEmJIHA",
"naverBlog": "blog.naver.com/toxnfill",
"gangnamUnni": "gangnamunni.com/hospitals/3702",
"tiktok": "tiktok.com/@toxnfillglobal",
"instagramEn": "instagram.com/toxnfill_global",
"facebookEn": "facebook.com/p/Toxnfill-Global-61557593068252"
}
},
{
"label": "더 압구정 성형외과",
"urls": {
"homepage": "theclinic.co.kr",
"youtube": "youtube.com/user/theplasticsurgery1",
"instagram": "instagram.com/the_plasticsurgery",
"facebook": "facebook.com/THEPS16445998",
"naverPlace": "",
"naverBlog": "blog.naver.com/with_theps",
"gangnamUnni": "gangnamunni.com/hospitals/30",
"tiktok": "",
"instagramEn": "instagram.com/the_plasticsurgery.en",
"facebookEn": "facebook.com/theps.english"
}
},
{
"label": "오라클 성형외과",
"urls": {
"homepage": "oracleclinic.com",
"youtube": "youtube.com/@oracle_medical_group",
"instagram": "instagram.com/oraclemedicalgroup",
"facebook": "facebook.com/oracleclinickr",
"naverPlace": "https://naver.me/GhbU3VtK",
"naverBlog": "",
"gangnamUnni": "gangnamunni.com/hospitals/125",
"tiktok": "tiktok.com/@oracleclinic_usa",
"instagramEn": "instagram.com/oracleclinic_global",
"facebookEn": "facebook.com/oracleclinicglobal"
}
}
]

View File

@ -8,6 +8,9 @@ class Channels(BaseModel):
facebook: str | None = None
naver_blog: str | None = None
gangnam_unni: str | None = None
tiktok: str | None = None
instagram_en: str | None = None
facebook_en: str | None = None
class AnalysisOptions(BaseModel):