- app.py +192 -86
- requirements.txt +2 -0
app.py
CHANGED
|
@@ -3,28 +3,27 @@ import shutil
|
|
| 3 |
import cv2
|
| 4 |
import base64
|
| 5 |
import uuid
|
| 6 |
-
import re
|
| 7 |
-
import time
|
| 8 |
-
import pandas as pd
|
| 9 |
from flask import Flask
|
|
|
|
|
|
|
| 10 |
import gradio as gr
|
| 11 |
-
|
| 12 |
-
|
| 13 |
from selenium import webdriver
|
| 14 |
from selenium.webdriver.common.by import By
|
| 15 |
from selenium.webdriver.chrome.service import Service
|
| 16 |
from selenium.webdriver.support.ui import WebDriverWait
|
| 17 |
from selenium.webdriver.support import expected_conditions as EC
|
| 18 |
-
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
| 19 |
from webdriver_manager.chrome import ChromeDriverManager
|
|
|
|
|
|
|
| 20 |
|
| 21 |
-
# --- GoogleReviewManager: 구글 리뷰 크롤링 및 포맷팅 ---
|
| 22 |
class GoogleReviewManager:
|
| 23 |
"""
|
| 24 |
-
구글 리뷰 크롤링을 통해 리뷰 데이터를 가져와 텍스트로 저장하고,
|
| 25 |
-
|
| 26 |
"""
|
| 27 |
-
def __init__(self, url, target_review_count=
|
| 28 |
self.url = url
|
| 29 |
self.target_review_count = target_review_count
|
| 30 |
self.reviews_text = self.fetch_reviews_text()
|
|
@@ -35,13 +34,14 @@ class GoogleReviewManager:
|
|
| 35 |
return "(구글 리뷰를 불러오지 못했습니다.)"
|
| 36 |
reviews = []
|
| 37 |
for index, row in df_reviews.iterrows():
|
| 38 |
-
# 예: [4.5 stars] Excellent service.
|
| 39 |
reviews.append(f"[{row['Rating']} stars] {row['Review Text']}")
|
|
|
|
| 40 |
return "\n".join(reviews)
|
| 41 |
|
| 42 |
@staticmethod
|
| 43 |
def format_google_reviews(reviews_text):
|
| 44 |
-
# 각 줄로
|
| 45 |
reviews = [line for line in reviews_text.split("\n") if line.strip() and "####" not in line]
|
| 46 |
formatted_reviews = []
|
| 47 |
for i, review in enumerate(reviews, start=1):
|
|
@@ -55,61 +55,116 @@ class GoogleReviewManager:
|
|
| 55 |
options.add_argument("--headless=new")
|
| 56 |
options.add_argument("--disable-gpu")
|
| 57 |
options.add_argument("--window-size=600,600")
|
|
|
|
| 58 |
options.add_argument("--lang=en")
|
| 59 |
-
options.add_argument(
|
|
|
|
| 60 |
driver = webdriver.Chrome(service=service, options=options)
|
| 61 |
print("웹 드라이버 설정 완료 (헤드리스 모드).")
|
| 62 |
except Exception as e:
|
| 63 |
print(f"웹 드라이버 설정 중 오류 발생: {e}")
|
| 64 |
-
|
| 65 |
|
| 66 |
reviews_data = []
|
| 67 |
-
processed_keys = set()
|
|
|
|
| 68 |
try:
|
| 69 |
driver.get(url)
|
|
|
|
| 70 |
time.sleep(3)
|
| 71 |
-
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
review_tab_button = None
|
| 74 |
possible_review_selectors = [
|
|
|
|
| 75 |
(By.XPATH, "//button[contains(text(), 'Reviews')]"),
|
| 76 |
(By.CSS_SELECTOR, "button[aria-label*='Reviews']"),
|
| 77 |
]
|
| 78 |
-
|
| 79 |
for selector in possible_review_selectors:
|
| 80 |
try:
|
| 81 |
-
review_tab_button =
|
|
|
|
|
|
|
|
|
|
| 82 |
break
|
| 83 |
except TimeoutException:
|
| 84 |
continue
|
| 85 |
-
if review_tab_button:
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
time.sleep(3)
|
| 88 |
|
| 89 |
-
# 최신순
|
| 90 |
try:
|
| 91 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
sort_button.click()
|
| 93 |
time.sleep(1)
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
newest_option.click()
|
|
|
|
| 96 |
time.sleep(3)
|
| 97 |
-
except
|
| 98 |
-
print(f"정렬
|
|
|
|
| 99 |
|
| 100 |
-
|
|
|
|
| 101 |
try:
|
| 102 |
-
scrollable_div =
|
|
|
|
|
|
|
|
|
|
| 103 |
except TimeoutException:
|
| 104 |
-
print("리뷰 스크롤 영역을
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
|
| 106 |
-
review_elements_selector = (By.CSS_SELECTOR, "div.jftiEf.fontBodyMedium")
|
| 107 |
loop_count = 0
|
| 108 |
max_loop = 50
|
| 109 |
while len(reviews_data) < TARGET_REVIEW_COUNT and loop_count < max_loop:
|
| 110 |
loop_count += 1
|
| 111 |
-
|
| 112 |
all_reviews = driver.find_elements(*review_elements_selector)
|
|
|
|
|
|
|
| 113 |
for review in all_reviews:
|
| 114 |
try:
|
| 115 |
reviewer_name = review.find_element(By.CSS_SELECTOR, "div.d4r55").text
|
|
@@ -123,44 +178,69 @@ class GoogleReviewManager:
|
|
| 123 |
if unique_key in processed_keys:
|
| 124 |
continue
|
| 125 |
processed_keys.add(unique_key)
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
more_button = review.find_element(By.CSS_SELECTOR, "button.w8nwRe.kyuRq")
|
| 129 |
-
driver.execute_script("arguments[0].click();", more_button)
|
| 130 |
-
time.sleep(0.3)
|
| 131 |
-
except Exception:
|
| 132 |
-
pass
|
| 133 |
-
try:
|
| 134 |
-
review_text = review.find_element(By.CSS_SELECTOR, "span.wiI7pd").text.strip()
|
| 135 |
-
except Exception:
|
| 136 |
-
review_text = ""
|
| 137 |
if review_text:
|
| 138 |
try:
|
| 139 |
-
|
|
|
|
| 140 |
except Exception:
|
| 141 |
rating = "N/A"
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
if len(reviews_data) >= TARGET_REVIEW_COUNT:
|
| 144 |
break
|
| 145 |
-
|
|
|
|
|
|
|
| 146 |
break
|
|
|
|
| 147 |
if scrollable_div:
|
| 148 |
-
for
|
| 149 |
-
driver.execute_script('arguments[0].scrollBy(0, 1000);', scrollable_div)
|
| 150 |
time.sleep(0.1)
|
|
|
|
|
|
|
| 151 |
time.sleep(2)
|
|
|
|
|
|
|
|
|
|
| 152 |
if reviews_data:
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
else:
|
| 155 |
-
|
| 156 |
except Exception as e:
|
| 157 |
-
print(f"
|
| 158 |
-
|
| 159 |
finally:
|
| 160 |
-
driver
|
| 161 |
-
|
| 162 |
|
| 163 |
-
|
|
|
|
|
|
|
| 164 |
class Config:
|
| 165 |
"""애플리케이션 설정 및 상수"""
|
| 166 |
FOOD_ITEMS = [
|
|
@@ -175,7 +255,7 @@ class Config:
|
|
| 175 |
]
|
| 176 |
# 알리바바 Qwen API 키 (기본값은 빈 문자열)
|
| 177 |
QWEN_API_KEY = ""
|
| 178 |
-
|
| 179 |
DEFAULT_PROMPT_TEMPLATE = (
|
| 180 |
"### Persona ###\n"
|
| 181 |
"You are an expert tip calculation assistant focusing on service quality observed in a video, and you also consider the user's review when evaluating the overall experience.\n\n"
|
|
@@ -224,11 +304,17 @@ class Config:
|
|
| 224 |
"Analysis: [Step-by-step explanation detailing:\n"
|
| 225 |
" - How you determined the bill amount;\n"
|
| 226 |
" - Your reasoning for the service quality classification should incorporate specific observations from the video (as described in the Video Caption), as well as a thorough analysis of the Recent Google Reviews, the user's review, and the user's star rating;\n"
|
| 227 |
-
" - How you chose the tip percentage within the guideline range, including the calculation details.]\n"
|
| 228 |
-
"
|
| 229 |
-
"Final Tip
|
| 230 |
-
"Final
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 231 |
)
|
|
|
|
| 232 |
CUSTOM_CSS = """
|
| 233 |
#food-container {
|
| 234 |
display: grid;
|
|
@@ -248,18 +334,21 @@ class Config:
|
|
| 248 |
"""
|
| 249 |
|
| 250 |
def __init__(self):
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
if not os.path.exists("images"):
|
| 253 |
print("경고: 'images' 폴더를 찾을 수 없습니다. 음식 이미지가 표시되지 않을 수 있습니다.")
|
| 254 |
for item in self.FOOD_ITEMS:
|
| 255 |
if not os.path.exists(item["image"]):
|
| 256 |
print(f"경고: 이미지 파일을 찾을 수 없습니다 - {item['image']}")
|
| 257 |
-
# 구글 리뷰 크롤링: 원하는 구글 리뷰 URL을 입력 (아래 예시는 임의 URL)
|
| 258 |
-
review_url = "https://www.google.com/maps/place/Wolfgang%E2%80%99s+Steakhouse/data=!3m1!4b1!4m6!3m5!1s0x357ca4778cdd1105:0x27d5ead252b66bfd!8m2!3d37.5244965!4d127.0414635!16s%2Fg%2F11c3pwpp26?hl=en&entry=ttu"
|
| 259 |
-
self.google_review_manager = GoogleReviewManager(review_url, target_review_count=2)
|
| 260 |
-
self.GOOGLE_REVIEWS = GoogleReviewManager.format_google_reviews(self.google_review_manager.reviews_text)
|
| 261 |
|
| 262 |
-
|
|
|
|
| 263 |
class ModelClients:
|
| 264 |
def __init__(self, config: Config):
|
| 265 |
self.config = config
|
|
@@ -273,7 +362,8 @@ class ModelClients:
|
|
| 273 |
with open(video_path, "rb") as video_file:
|
| 274 |
return base64.b64encode(video_file.read()).decode("utf-8")
|
| 275 |
|
| 276 |
-
|
|
|
|
| 277 |
class VideoProcessor:
|
| 278 |
def extract_video_frames(self, video_path, output_folder=None, fps=1):
|
| 279 |
if not video_path:
|
|
@@ -288,7 +378,7 @@ class VideoProcessor:
|
|
| 288 |
frame_paths = []
|
| 289 |
frame_rate = cap.get(cv2.CAP_PROP_FPS)
|
| 290 |
if not frame_rate or frame_rate == 0:
|
| 291 |
-
print("경고: FPS를 읽을 수 없습니다, 기본값 4
|
| 292 |
frame_rate = 4.0
|
| 293 |
frame_interval = int(frame_rate / fps) if fps > 0 else 1
|
| 294 |
if frame_interval <= 0:
|
|
@@ -336,7 +426,8 @@ class VideoProcessor:
|
|
| 336 |
except OSError as e:
|
| 337 |
print(f"프레임 폴더 삭제 오류: {e}")
|
| 338 |
|
| 339 |
-
|
|
|
|
| 340 |
class TipCalculator:
|
| 341 |
def __init__(self, config: Config, model_clients: ModelClients, video_processor: VideoProcessor):
|
| 342 |
self.config = config
|
|
@@ -350,7 +441,9 @@ class TipCalculator:
|
|
| 350 |
tip_amount = 0.0
|
| 351 |
total_bill = 0.0
|
| 352 |
|
| 353 |
-
|
|
|
|
|
|
|
| 354 |
if analysis_match:
|
| 355 |
analysis = analysis_match.group(1).strip()
|
| 356 |
else:
|
|
@@ -358,7 +451,8 @@ class TipCalculator:
|
|
| 358 |
if analysis_match_alt:
|
| 359 |
analysis = analysis_match_alt.group(1).strip()
|
| 360 |
|
| 361 |
-
|
|
|
|
| 362 |
re.DOTALL | re.IGNORECASE)
|
| 363 |
if percentage_match:
|
| 364 |
try:
|
|
@@ -367,7 +461,8 @@ class TipCalculator:
|
|
| 367 |
print(f"경고: Tip Percentage 변환 실패 - {percentage_match.group(1)}")
|
| 368 |
tip_percentage = 0.0
|
| 369 |
|
| 370 |
-
|
|
|
|
| 371 |
if tip_match:
|
| 372 |
try:
|
| 373 |
tip_amount = float(tip_match.group(1))
|
|
@@ -377,13 +472,13 @@ class TipCalculator:
|
|
| 377 |
else:
|
| 378 |
print(f"경고: 출력에서 Tip Amount를 찾을 수 없습니다:\n{output_text}")
|
| 379 |
|
| 380 |
-
|
|
|
|
| 381 |
if total_match:
|
| 382 |
try:
|
| 383 |
total_bill = float(total_match.group(1))
|
| 384 |
except ValueError:
|
| 385 |
print(f"경고: Total Bill 변환 실패 - {total_match.group(1)}")
|
| 386 |
-
|
| 387 |
if len(analysis) < 20 and analysis == "Analysis not found.":
|
| 388 |
analysis = output_text
|
| 389 |
|
|
@@ -426,28 +521,24 @@ Task 2: Provide a short chronological summary of the entire scene.
|
|
| 426 |
if not caption_text.strip():
|
| 427 |
caption_text = "(No caption from Omni)"
|
| 428 |
user_review = user_review.strip() if user_review else "(No user review)"
|
| 429 |
-
# 새로운 프롬프트 템플릿에 구글 리뷰를 포함하도록 업데이트
|
| 430 |
if custom_prompt is None:
|
| 431 |
prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
| 432 |
calculated_subtotal=calculated_subtotal,
|
| 433 |
star_rating=star_rating,
|
| 434 |
-
user_review=user_review
|
| 435 |
-
google_reviews=self.config.GOOGLE_REVIEWS
|
| 436 |
)
|
| 437 |
else:
|
| 438 |
try:
|
| 439 |
prompt = custom_prompt.format(
|
| 440 |
calculated_subtotal=calculated_subtotal,
|
| 441 |
star_rating=star_rating,
|
| 442 |
-
user_review=user_review
|
| 443 |
-
google_reviews=self.config.GOOGLE_REVIEWS
|
| 444 |
)
|
| 445 |
except:
|
| 446 |
prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
| 447 |
calculated_subtotal=calculated_subtotal,
|
| 448 |
star_rating=star_rating,
|
| 449 |
-
user_review=user_review
|
| 450 |
-
google_reviews=self.config.GOOGLE_REVIEWS
|
| 451 |
)
|
| 452 |
final_prompt = prompt.replace("{caption_text}", caption_text)
|
| 453 |
qvq_result = self.model_clients.qwen_client.chat.completions.create(
|
|
@@ -486,7 +577,8 @@ Task 2: Provide a short chronological summary of the entire scene.
|
|
| 486 |
total_bill_output = f"${total_bill:.2f}"
|
| 487 |
return analysis_output, tip_output, total_bill_output
|
| 488 |
|
| 489 |
-
|
|
|
|
| 490 |
class UIHandler:
|
| 491 |
def __init__(self, config: Config, tip_calculator: TipCalculator, video_processor: VideoProcessor):
|
| 492 |
self.config = config
|
|
@@ -494,21 +586,29 @@ class UIHandler:
|
|
| 494 |
self.video_processor = video_processor
|
| 495 |
|
| 496 |
def update_subtotal_and_prompt(self, *args):
|
|
|
|
| 497 |
num_food_items = len(self.config.FOOD_ITEMS)
|
| 498 |
quantities = args[:num_food_items]
|
| 499 |
star_rating = args[num_food_items]
|
| 500 |
user_review = args[num_food_items + 1]
|
|
|
|
| 501 |
calculated_subtotal = 0.0
|
| 502 |
for i in range(num_food_items):
|
| 503 |
calculated_subtotal += self.config.FOOD_ITEMS[i]['price'] * quantities[i]
|
|
|
|
| 504 |
user_review_text = user_review.strip() if user_review and user_review.strip() else "(No user review provided)"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 505 |
updated_prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
| 506 |
calculated_subtotal=calculated_subtotal,
|
| 507 |
star_rating=star_rating,
|
| 508 |
user_review=user_review_text,
|
| 509 |
-
google_reviews=
|
| 510 |
)
|
| 511 |
updated_prompt = updated_prompt.replace("{caption_text}", "{{caption_text}}")
|
|
|
|
| 512 |
return calculated_subtotal, updated_prompt
|
| 513 |
|
| 514 |
def compute_tip(self, alibaba_key, video_file_obj, subtotal, star_rating, user_review, custom_prompt_text):
|
|
@@ -558,6 +658,7 @@ class UIHandler:
|
|
| 558 |
analysis, tip_disp, total_bill_disp, prompt_out, vid_out = self.compute_tip(
|
| 559 |
alibaba_key, video_file_obj, subtotal, star_rating, review, prompt
|
| 560 |
)
|
|
|
|
| 561 |
invoice = self.update_invoice_summary(*quantities, tip_disp, total_bill_disp)
|
| 562 |
return analysis, tip_disp, total_bill_disp, prompt_out, vid_out, invoice
|
| 563 |
|
|
@@ -593,7 +694,8 @@ class UIHandler:
|
|
| 593 |
def process_payment(self, total_bill):
|
| 594 |
return f"{total_bill} 결제되었습니다."
|
| 595 |
|
| 596 |
-
|
|
|
|
| 597 |
class App:
|
| 598 |
def __init__(self):
|
| 599 |
self.config = Config()
|
|
@@ -604,7 +706,8 @@ class App:
|
|
| 604 |
self.flask_app = Flask(__name__)
|
| 605 |
|
| 606 |
def create_gradio_blocks(self):
|
| 607 |
-
with gr.Blocks(title="Video Tip Calculation Interface", theme=gr.themes.Soft(),
|
|
|
|
| 608 |
gr.Markdown("## Video Tip Calculation Interface (Structured)")
|
| 609 |
quantity_inputs = []
|
| 610 |
subtotal_display = gr.Number(label="Subtotal ($)", value=0.0, interactive=False, visible=False)
|
|
@@ -663,7 +766,7 @@ class App:
|
|
| 663 |
calculated_subtotal=0.0,
|
| 664 |
star_rating=3,
|
| 665 |
user_review="(No user review provided)",
|
| 666 |
-
google_reviews=self.config.GOOGLE_REVIEWS
|
| 667 |
).replace("{caption_text}", "{{caption_text}}")
|
| 668 |
)
|
| 669 |
gr.Markdown("### 6. AI Analysis")
|
|
@@ -690,7 +793,9 @@ class App:
|
|
| 690 |
outputs=order_summary_display
|
| 691 |
)
|
| 692 |
compute_inputs = [alibaba_key_input, video_input, subtotal_display, rating_input, review_input, prompt_display] + quantity_inputs
|
| 693 |
-
compute_outputs = [
|
|
|
|
|
|
|
| 694 |
qwen_btn.click(
|
| 695 |
fn=lambda alibaba_key, vid, sub, rat, rev, prom, *qty: self.ui_handler.auto_tip_and_invoice(
|
| 696 |
alibaba_key, vid, sub, rat, rev, prom, *qty
|
|
@@ -740,6 +845,7 @@ class App:
|
|
| 740 |
return "Hello Flask"
|
| 741 |
self.flask_app.run(host="0.0.0.0", port=5000, debug=True)
|
| 742 |
|
|
|
|
| 743 |
if __name__ == "__main__":
|
| 744 |
app = App()
|
| 745 |
app.run_gradio()
|
|
|
|
| 3 |
import cv2
|
| 4 |
import base64
|
| 5 |
import uuid
|
|
|
|
|
|
|
|
|
|
| 6 |
from flask import Flask
|
| 7 |
+
from ollama import Client
|
| 8 |
+
import openai
|
| 9 |
import gradio as gr
|
| 10 |
+
import re
|
| 11 |
+
import pandas as pd
|
| 12 |
from selenium import webdriver
|
| 13 |
from selenium.webdriver.common.by import By
|
| 14 |
from selenium.webdriver.chrome.service import Service
|
| 15 |
from selenium.webdriver.support.ui import WebDriverWait
|
| 16 |
from selenium.webdriver.support import expected_conditions as EC
|
|
|
|
| 17 |
from webdriver_manager.chrome import ChromeDriverManager
|
| 18 |
+
from selenium.common.exceptions import NoSuchElementException, TimeoutException
|
| 19 |
+
import time
|
| 20 |
|
|
|
|
| 21 |
class GoogleReviewManager:
|
| 22 |
"""
|
| 23 |
+
구글 리뷰 크롤링을 통해 리뷰 데이터를 한 번만 가져와 텍스트로 저장하고,
|
| 24 |
+
DEFAULT_PROMPT_TEMPLATE에 적용할 리뷰 문자열을 생성하는 클래스.
|
| 25 |
"""
|
| 26 |
+
def __init__(self, url, target_review_count=20):
|
| 27 |
self.url = url
|
| 28 |
self.target_review_count = target_review_count
|
| 29 |
self.reviews_text = self.fetch_reviews_text()
|
|
|
|
| 34 |
return "(구글 리뷰를 불러오지 못했습니다.)"
|
| 35 |
reviews = []
|
| 36 |
for index, row in df_reviews.iterrows():
|
| 37 |
+
# 예: [4.5 stars] Excellent service and food.
|
| 38 |
reviews.append(f"[{row['Rating']} stars] {row['Review Text']}")
|
| 39 |
+
# 각 리뷰를 개행 문자로 구분하여 하나의 문자열로 생성
|
| 40 |
return "\n".join(reviews)
|
| 41 |
|
| 42 |
@staticmethod
|
| 43 |
def format_google_reviews(reviews_text):
|
| 44 |
+
# 각 줄로 분리하고, 이미 "####"가 포함된 줄은 제외하여 순수한 리뷰 내용만 남김
|
| 45 |
reviews = [line for line in reviews_text.split("\n") if line.strip() and "####" not in line]
|
| 46 |
formatted_reviews = []
|
| 47 |
for i, review in enumerate(reviews, start=1):
|
|
|
|
| 55 |
options.add_argument("--headless=new")
|
| 56 |
options.add_argument("--disable-gpu")
|
| 57 |
options.add_argument("--window-size=600,600")
|
| 58 |
+
# 언어 설정 (한글 리뷰를 원하면 "--lang=ko"로 변경)
|
| 59 |
options.add_argument("--lang=en")
|
| 60 |
+
options.add_argument(
|
| 61 |
+
"user-agent=Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36")
|
| 62 |
driver = webdriver.Chrome(service=service, options=options)
|
| 63 |
print("웹 드라이버 설정 완료 (헤드리스 모드).")
|
| 64 |
except Exception as e:
|
| 65 |
print(f"웹 드라이버 설정 중 오류 발생: {e}")
|
| 66 |
+
exit()
|
| 67 |
|
| 68 |
reviews_data = []
|
| 69 |
+
processed_keys = set() # 중복 리뷰 방지를 위한 고유 키 저장
|
| 70 |
+
|
| 71 |
try:
|
| 72 |
driver.get(url)
|
| 73 |
+
print("Google Maps 접속 완료.")
|
| 74 |
time.sleep(3)
|
| 75 |
+
zoom_level = 0.7
|
| 76 |
+
driver.execute_script(f"document.body.style.zoom = '{zoom_level}'")
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
overall_rating_selector = (By.CSS_SELECTOR, "div.F7nice span[aria-hidden='true']")
|
| 80 |
+
overall_rating_element = WebDriverWait(driver, 10).until(
|
| 81 |
+
EC.visibility_of_element_located(overall_rating_selector)
|
| 82 |
+
)
|
| 83 |
+
overall_rating_text = overall_rating_element.text
|
| 84 |
+
print(f"총 평점 찾음: {overall_rating_text}")
|
| 85 |
+
except (TimeoutException, NoSuchElementException):
|
| 86 |
+
print("총 평점 요소를 찾지 못했습니다.")
|
| 87 |
+
|
| 88 |
+
# 리뷰 탭으로 이동
|
| 89 |
review_tab_button = None
|
| 90 |
possible_review_selectors = [
|
| 91 |
+
(By.XPATH, "//button[@role='tab'][contains(., 'Reviews')]"),
|
| 92 |
(By.XPATH, "//button[contains(text(), 'Reviews')]"),
|
| 93 |
(By.CSS_SELECTOR, "button[aria-label*='Reviews']"),
|
| 94 |
]
|
| 95 |
+
wait_for_review_tab = WebDriverWait(driver, 10)
|
| 96 |
for selector in possible_review_selectors:
|
| 97 |
try:
|
| 98 |
+
review_tab_button = wait_for_review_tab.until(
|
| 99 |
+
EC.element_to_be_clickable(selector)
|
| 100 |
+
)
|
| 101 |
+
print(f"리뷰 탭 버튼 찾음 (방식: {selector[0]}, 값: {selector[1]})")
|
| 102 |
break
|
| 103 |
except TimeoutException:
|
| 104 |
continue
|
| 105 |
+
if not review_tab_button:
|
| 106 |
+
print("리뷰 탭 버튼을 찾을 수 없습니다.")
|
| 107 |
+
raise NoSuchElementException("리뷰 탭 버튼 없음")
|
| 108 |
+
review_tab_button.click()
|
| 109 |
time.sleep(3)
|
| 110 |
|
| 111 |
+
# 정렬 버튼 클릭 후 '최신순' 선택
|
| 112 |
try:
|
| 113 |
+
sort_button_selector = (By.XPATH,
|
| 114 |
+
"//button[contains(@aria-label, '정렬 기준') or contains(@aria-label, 'Sort by') or .//span[contains(text(), 'Sort')]]")
|
| 115 |
+
sort_button = WebDriverWait(driver, 10).until(
|
| 116 |
+
EC.element_to_be_clickable(sort_button_selector)
|
| 117 |
+
)
|
| 118 |
+
print("정렬 기준 버튼 찾음. 클릭 시도...")
|
| 119 |
sort_button.click()
|
| 120 |
time.sleep(1)
|
| 121 |
+
newest_option_selector = (By.XPATH, "//div[@role='menuitemradio'][contains(., 'Newest')]")
|
| 122 |
+
newest_option = WebDriverWait(driver, 10).until(
|
| 123 |
+
EC.element_to_be_clickable(newest_option_selector)
|
| 124 |
+
)
|
| 125 |
+
print("최신순 옵션 찾음. 클릭 시도...")
|
| 126 |
newest_option.click()
|
| 127 |
+
print("최신순으로 정렬 적용됨. 잠시 대기...")
|
| 128 |
time.sleep(3)
|
| 129 |
+
except (TimeoutException, NoSuchElementException) as e:
|
| 130 |
+
print(f"정렬 적용 중 오류 발생: {e}. 기본 정렬 상태로 진행합니다.")
|
| 131 |
+
time.sleep(3)
|
| 132 |
|
| 133 |
+
scrollable_div_selector = (By.CSS_SELECTOR, "div.m6QErb.DxyBCb.kA9KIf.dS8AEf.XiKgde[tabindex='-1']")
|
| 134 |
+
review_elements_selector = (By.CSS_SELECTOR, "div.jftiEf.fontBodyMedium")
|
| 135 |
try:
|
| 136 |
+
scrollable_div = WebDriverWait(driver, 15).until(
|
| 137 |
+
EC.presence_of_element_located(scrollable_div_selector)
|
| 138 |
+
)
|
| 139 |
+
print("리뷰 스크롤 영역 찾음.")
|
| 140 |
except TimeoutException:
|
| 141 |
+
print("리뷰 스크롤 영역을 찾을 수 없습니다.")
|
| 142 |
+
scrollable_div = None
|
| 143 |
+
time.sleep(5)
|
| 144 |
+
|
| 145 |
+
def get_review_text(review):
|
| 146 |
+
try:
|
| 147 |
+
more_button = review.find_element(By.CSS_SELECTOR, "button.w8nwRe.kyuRq")
|
| 148 |
+
driver.execute_script("arguments[0].scrollIntoView(true);", more_button)
|
| 149 |
+
time.sleep(0.3)
|
| 150 |
+
driver.execute_script("arguments[0].click();", more_button)
|
| 151 |
+
time.sleep(0.5)
|
| 152 |
+
except Exception:
|
| 153 |
+
pass
|
| 154 |
+
try:
|
| 155 |
+
review_text = review.find_element(By.CSS_SELECTOR, "span.wiI7pd").text
|
| 156 |
+
return review_text.strip()
|
| 157 |
+
except Exception:
|
| 158 |
+
return ""
|
| 159 |
|
|
|
|
| 160 |
loop_count = 0
|
| 161 |
max_loop = 50
|
| 162 |
while len(reviews_data) < TARGET_REVIEW_COUNT and loop_count < max_loop:
|
| 163 |
loop_count += 1
|
| 164 |
+
previous_count = len(reviews_data)
|
| 165 |
all_reviews = driver.find_elements(*review_elements_selector)
|
| 166 |
+
print(f"Loop {loop_count}: 총 {len(all_reviews)}개의 리뷰 요소 발견.")
|
| 167 |
+
|
| 168 |
for review in all_reviews:
|
| 169 |
try:
|
| 170 |
reviewer_name = review.find_element(By.CSS_SELECTOR, "div.d4r55").text
|
|
|
|
| 178 |
if unique_key in processed_keys:
|
| 179 |
continue
|
| 180 |
processed_keys.add(unique_key)
|
| 181 |
+
|
| 182 |
+
review_text = get_review_text(review)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
if review_text:
|
| 184 |
try:
|
| 185 |
+
rating_span = review.find_element(By.CSS_SELECTOR, "span.kvMYJc")
|
| 186 |
+
rating = rating_span.get_attribute("aria-label")
|
| 187 |
except Exception:
|
| 188 |
rating = "N/A"
|
| 189 |
+
review_info = {
|
| 190 |
+
"reviewer_name": reviewer_name,
|
| 191 |
+
"rating": rating,
|
| 192 |
+
"date": review_date,
|
| 193 |
+
"text": review_text.replace('\n', ' ')
|
| 194 |
+
}
|
| 195 |
+
reviews_data.append(review_info)
|
| 196 |
+
print(f"리뷰 추가: {reviewer_name}, {review_date}")
|
| 197 |
if len(reviews_data) >= TARGET_REVIEW_COUNT:
|
| 198 |
break
|
| 199 |
+
|
| 200 |
+
if len(reviews_data) == previous_count:
|
| 201 |
+
print("새로운 리뷰가 추가되지 않아 스크롤을 중단합니다.")
|
| 202 |
break
|
| 203 |
+
|
| 204 |
if scrollable_div:
|
| 205 |
+
for i in range(20):
|
|
|
|
| 206 |
time.sleep(0.1)
|
| 207 |
+
scroll_amount = 1000
|
| 208 |
+
driver.execute_script('arguments[0].scrollBy(0, arguments[1]);', scrollable_div, scroll_amount)
|
| 209 |
time.sleep(2)
|
| 210 |
+
else:
|
| 211 |
+
break
|
| 212 |
+
|
| 213 |
if reviews_data:
|
| 214 |
+
review_list = []
|
| 215 |
+
for review in reviews_data[:TARGET_REVIEW_COUNT]:
|
| 216 |
+
rating_str = review['rating']
|
| 217 |
+
if rating_str != "N/A":
|
| 218 |
+
try:
|
| 219 |
+
rating_num = float(rating_str.split()[0])
|
| 220 |
+
except Exception:
|
| 221 |
+
rating_num = None
|
| 222 |
+
else:
|
| 223 |
+
rating_num = None
|
| 224 |
+
|
| 225 |
+
review_list.append({
|
| 226 |
+
"Name": review['reviewer_name'],
|
| 227 |
+
"Rating": rating_num,
|
| 228 |
+
"Date / Time Ago": review['date'],
|
| 229 |
+
"Review Text": review['text']
|
| 230 |
+
})
|
| 231 |
+
df_reviews = pd.DataFrame(review_list)
|
| 232 |
else:
|
| 233 |
+
df_reviews = pd.DataFrame()
|
| 234 |
except Exception as e:
|
| 235 |
+
print(f"스크립트 실행 중 예기치 않은 오류 발생: {e}")
|
| 236 |
+
df_reviews = pd.DataFrame()
|
| 237 |
finally:
|
| 238 |
+
if 'driver' in locals() and driver:
|
| 239 |
+
driver.quit()
|
| 240 |
|
| 241 |
+
return df_reviews
|
| 242 |
+
|
| 243 |
+
# --- Config 클래스 (Gemma, GPT4o 제거, Qwen만 사용) ---
|
| 244 |
class Config:
|
| 245 |
"""애플리케이션 설정 및 상수"""
|
| 246 |
FOOD_ITEMS = [
|
|
|
|
| 255 |
]
|
| 256 |
# 알리바바 Qwen API 키 (기본값은 빈 문자열)
|
| 257 |
QWEN_API_KEY = ""
|
| 258 |
+
|
| 259 |
DEFAULT_PROMPT_TEMPLATE = (
|
| 260 |
"### Persona ###\n"
|
| 261 |
"You are an expert tip calculation assistant focusing on service quality observed in a video, and you also consider the user's review when evaluating the overall experience.\n\n"
|
|
|
|
| 304 |
"Analysis: [Step-by-step explanation detailing:\n"
|
| 305 |
" - How you determined the bill amount;\n"
|
| 306 |
" - Your reasoning for the service quality classification should incorporate specific observations from the video (as described in the Video Caption), as well as a thorough analysis of the Recent Google Reviews, the user's review, and the user's star rating;\n"
|
| 307 |
+
" - How you chose the tip percentage within the guideline range, including the calculation details.]\n\n"
|
| 308 |
+
"### Example Output Indicators(Only Example) ###\n"
|
| 309 |
+
"**Final Tip Percentage**: 2.0%\n"
|
| 310 |
+
"**Final Tip Amount**: $0.50\n"
|
| 311 |
+
"**Final Total Bill**: $25.50\n\n"
|
| 312 |
+
"### Output Indicators ###\n"
|
| 313 |
+
"**Final Tip Percentage**: [X]% (only floating point)\n"
|
| 314 |
+
"**Final Tip Amount**: $[Calculated Tip]\n"
|
| 315 |
+
"**Final Total Bill**: $[Subtotal + Tip]\n"
|
| 316 |
)
|
| 317 |
+
|
| 318 |
CUSTOM_CSS = """
|
| 319 |
#food-container {
|
| 320 |
display: grid;
|
|
|
|
| 334 |
"""
|
| 335 |
|
| 336 |
def __init__(self):
|
| 337 |
+
review_url = "https://www.google.com/maps/place/Wolfgang%E2%80%99s+Steakhouse/data=!3m1!4b1!4m6!3m5!1s0x357ca4778cdd1105:0x27d5ead252b66bfd!8m2!3d37.5244965!4d127.0414635!16s%2Fg%2F11c3pwpp26?hl=en&entry=ttu&g_ep=EgoyMDI1MDQwMi4xIKXMDSoASAFQAw%3D%3D"
|
| 338 |
+
self.google_review_manager = GoogleReviewManager(review_url, target_review_count=2)
|
| 339 |
+
# 여기서 정적 메서드를 통해 리뷰를 미리 포맷하여 저장합니다.
|
| 340 |
+
self.GOOGLE_REVIEWS = GoogleReviewManager.format_google_reviews(
|
| 341 |
+
self.google_review_manager.reviews_text
|
| 342 |
+
)
|
| 343 |
+
# 이미지 디렉토리 확인
|
| 344 |
if not os.path.exists("images"):
|
| 345 |
print("경고: 'images' 폴더를 찾을 수 없습니다. 음식 이미지가 표시되지 않을 수 있습니다.")
|
| 346 |
for item in self.FOOD_ITEMS:
|
| 347 |
if not os.path.exists(item["image"]):
|
| 348 |
print(f"경고: 이미지 파일을 찾을 수 없습니다 - {item['image']}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 349 |
|
| 350 |
+
|
| 351 |
+
# --- ModelClients (알리바바 Qwen API만 사용) ---
|
| 352 |
class ModelClients:
|
| 353 |
def __init__(self, config: Config):
|
| 354 |
self.config = config
|
|
|
|
| 362 |
with open(video_path, "rb") as video_file:
|
| 363 |
return base64.b64encode(video_file.read()).decode("utf-8")
|
| 364 |
|
| 365 |
+
|
| 366 |
+
# --- VideoProcessor: 비디오 프레임 추출 ---
|
| 367 |
class VideoProcessor:
|
| 368 |
def extract_video_frames(self, video_path, output_folder=None, fps=1):
|
| 369 |
if not video_path:
|
|
|
|
| 378 |
frame_paths = []
|
| 379 |
frame_rate = cap.get(cv2.CAP_PROP_FPS)
|
| 380 |
if not frame_rate or frame_rate == 0:
|
| 381 |
+
print("경고: FPS를 읽을 수 없습니다, 기본값 4으로 설정합니다.")
|
| 382 |
frame_rate = 4.0
|
| 383 |
frame_interval = int(frame_rate / fps) if fps > 0 else 1
|
| 384 |
if frame_interval <= 0:
|
|
|
|
| 426 |
except OSError as e:
|
| 427 |
print(f"프레임 폴더 삭제 오류: {e}")
|
| 428 |
|
| 429 |
+
|
| 430 |
+
# --- TipCalculator (알리바바 Qwen API를 사용한 팁 계산) ---
|
| 431 |
class TipCalculator:
|
| 432 |
def __init__(self, config: Config, model_clients: ModelClients, video_processor: VideoProcessor):
|
| 433 |
self.config = config
|
|
|
|
| 441 |
tip_amount = 0.0
|
| 442 |
total_bill = 0.0
|
| 443 |
|
| 444 |
+
# Analysis 부분: "Analysis:" 이후부터 "**Final Tip Percentage**" 이전까지 추출
|
| 445 |
+
analysis_match = re.search(r"Analysis:\s*(.*?)\*\*Final Tip Percentage\*\*", output_text,
|
| 446 |
+
re.DOTALL | re.IGNORECASE)
|
| 447 |
if analysis_match:
|
| 448 |
analysis = analysis_match.group(1).strip()
|
| 449 |
else:
|
|
|
|
| 451 |
if analysis_match_alt:
|
| 452 |
analysis = analysis_match_alt.group(1).strip()
|
| 453 |
|
| 454 |
+
# **Final Tip Percentage** 추출 (예: **Final Tip Percentage**: 2.00%)
|
| 455 |
+
percentage_match = re.search(r"\*\*Final Tip Percentage\*\*:\s*([0-9]+(?:\.[0-9]+)?)%", output_text,
|
| 456 |
re.DOTALL | re.IGNORECASE)
|
| 457 |
if percentage_match:
|
| 458 |
try:
|
|
|
|
| 461 |
print(f"경고: Tip Percentage 변환 실패 - {percentage_match.group(1)}")
|
| 462 |
tip_percentage = 0.0
|
| 463 |
|
| 464 |
+
# **Final Tip Amount** 추출 (예: **Final Tip Amount**: $1.44)
|
| 465 |
+
tip_match = re.search(r"\*\*Final Tip Amount\*\*:\s*\$?\s*([0-9]+(?:\.[0-9]+)?)", output_text, re.IGNORECASE)
|
| 466 |
if tip_match:
|
| 467 |
try:
|
| 468 |
tip_amount = float(tip_match.group(1))
|
|
|
|
| 472 |
else:
|
| 473 |
print(f"경고: 출력에서 Tip Amount를 찾을 수 없습니다:\n{output_text}")
|
| 474 |
|
| 475 |
+
# **Final Total Bill** 추출 (예: **Final Total Bill**: $73.44)
|
| 476 |
+
total_match = re.search(r"\*\*Final Total Bill\*\*:\s*\$?\s*([0-9]+(?:\.[0-9]+)?)", output_text, re.IGNORECASE)
|
| 477 |
if total_match:
|
| 478 |
try:
|
| 479 |
total_bill = float(total_match.group(1))
|
| 480 |
except ValueError:
|
| 481 |
print(f"경고: Total Bill 변환 실패 - {total_match.group(1)}")
|
|
|
|
| 482 |
if len(analysis) < 20 and analysis == "Analysis not found.":
|
| 483 |
analysis = output_text
|
| 484 |
|
|
|
|
| 521 |
if not caption_text.strip():
|
| 522 |
caption_text = "(No caption from Omni)"
|
| 523 |
user_review = user_review.strip() if user_review else "(No user review)"
|
|
|
|
| 524 |
if custom_prompt is None:
|
| 525 |
prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
| 526 |
calculated_subtotal=calculated_subtotal,
|
| 527 |
star_rating=star_rating,
|
| 528 |
+
user_review=user_review
|
|
|
|
| 529 |
)
|
| 530 |
else:
|
| 531 |
try:
|
| 532 |
prompt = custom_prompt.format(
|
| 533 |
calculated_subtotal=calculated_subtotal,
|
| 534 |
star_rating=star_rating,
|
| 535 |
+
user_review=user_review
|
|
|
|
| 536 |
)
|
| 537 |
except:
|
| 538 |
prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
| 539 |
calculated_subtotal=calculated_subtotal,
|
| 540 |
star_rating=star_rating,
|
| 541 |
+
user_review=user_review
|
|
|
|
| 542 |
)
|
| 543 |
final_prompt = prompt.replace("{caption_text}", caption_text)
|
| 544 |
qvq_result = self.model_clients.qwen_client.chat.completions.create(
|
|
|
|
| 577 |
total_bill_output = f"${total_bill:.2f}"
|
| 578 |
return analysis_output, tip_output, total_bill_output
|
| 579 |
|
| 580 |
+
|
| 581 |
+
# --- UIHandler: Gradio 인터페이스 이벤트 처리 (알리바바 API 키 입력 포함) ---
|
| 582 |
class UIHandler:
|
| 583 |
def __init__(self, config: Config, tip_calculator: TipCalculator, video_processor: VideoProcessor):
|
| 584 |
self.config = config
|
|
|
|
| 586 |
self.video_processor = video_processor
|
| 587 |
|
| 588 |
def update_subtotal_and_prompt(self, *args):
|
| 589 |
+
"""사용자 입력에 따라 소계 및 프롬프트 업데이트"""
|
| 590 |
num_food_items = len(self.config.FOOD_ITEMS)
|
| 591 |
quantities = args[:num_food_items]
|
| 592 |
star_rating = args[num_food_items]
|
| 593 |
user_review = args[num_food_items + 1]
|
| 594 |
+
|
| 595 |
calculated_subtotal = 0.0
|
| 596 |
for i in range(num_food_items):
|
| 597 |
calculated_subtotal += self.config.FOOD_ITEMS[i]['price'] * quantities[i]
|
| 598 |
+
|
| 599 |
user_review_text = user_review.strip() if user_review and user_review.strip() else "(No user review provided)"
|
| 600 |
+
|
| 601 |
+
# Google 리뷰 텍스트를 동적으로 포맷팅 (정적 메서드 호출)
|
| 602 |
+
formatted_google_reviews = GoogleReviewManager.format_google_reviews(self.config.GOOGLE_REVIEWS)
|
| 603 |
+
# print(formatted_google_reviews)
|
| 604 |
updated_prompt = self.config.DEFAULT_PROMPT_TEMPLATE.format(
|
| 605 |
calculated_subtotal=calculated_subtotal,
|
| 606 |
star_rating=star_rating,
|
| 607 |
user_review=user_review_text,
|
| 608 |
+
google_reviews=formatted_google_reviews
|
| 609 |
)
|
| 610 |
updated_prompt = updated_prompt.replace("{caption_text}", "{{caption_text}}")
|
| 611 |
+
|
| 612 |
return calculated_subtotal, updated_prompt
|
| 613 |
|
| 614 |
def compute_tip(self, alibaba_key, video_file_obj, subtotal, star_rating, user_review, custom_prompt_text):
|
|
|
|
| 658 |
analysis, tip_disp, total_bill_disp, prompt_out, vid_out = self.compute_tip(
|
| 659 |
alibaba_key, video_file_obj, subtotal, star_rating, review, prompt
|
| 660 |
)
|
| 661 |
+
#print(analysis, tip_disp, total_bill_disp, prompt_out, vid_out)
|
| 662 |
invoice = self.update_invoice_summary(*quantities, tip_disp, total_bill_disp)
|
| 663 |
return analysis, tip_disp, total_bill_disp, prompt_out, vid_out, invoice
|
| 664 |
|
|
|
|
| 694 |
def process_payment(self, total_bill):
|
| 695 |
return f"{total_bill} 결제되었습니다."
|
| 696 |
|
| 697 |
+
|
| 698 |
+
# --- App: 모든 컴포넌트 연결 및 Gradio 인터페이스 실행 ---
|
| 699 |
class App:
|
| 700 |
def __init__(self):
|
| 701 |
self.config = Config()
|
|
|
|
| 706 |
self.flask_app = Flask(__name__)
|
| 707 |
|
| 708 |
def create_gradio_blocks(self):
|
| 709 |
+
with gr.Blocks(title="Video Tip Calculation Interface", theme=gr.themes.Soft(),
|
| 710 |
+
css=self.config.CUSTOM_CSS) as interface:
|
| 711 |
gr.Markdown("## Video Tip Calculation Interface (Structured)")
|
| 712 |
quantity_inputs = []
|
| 713 |
subtotal_display = gr.Number(label="Subtotal ($)", value=0.0, interactive=False, visible=False)
|
|
|
|
| 766 |
calculated_subtotal=0.0,
|
| 767 |
star_rating=3,
|
| 768 |
user_review="(No user review provided)",
|
| 769 |
+
google_reviews=GoogleReviewManager.format_google_reviews(self.config.GOOGLE_REVIEWS)
|
| 770 |
).replace("{caption_text}", "{{caption_text}}")
|
| 771 |
)
|
| 772 |
gr.Markdown("### 6. AI Analysis")
|
|
|
|
| 793 |
outputs=order_summary_display
|
| 794 |
)
|
| 795 |
compute_inputs = [alibaba_key_input, video_input, subtotal_display, rating_input, review_input, prompt_display] + quantity_inputs
|
| 796 |
+
compute_outputs = [
|
| 797 |
+
analysis_display, tip_display, total_bill_display, prompt_display, video_input, order_summary_display
|
| 798 |
+
]
|
| 799 |
qwen_btn.click(
|
| 800 |
fn=lambda alibaba_key, vid, sub, rat, rev, prom, *qty: self.ui_handler.auto_tip_and_invoice(
|
| 801 |
alibaba_key, vid, sub, rat, rev, prom, *qty
|
|
|
|
| 845 |
return "Hello Flask"
|
| 846 |
self.flask_app.run(host="0.0.0.0", port=5000, debug=True)
|
| 847 |
|
| 848 |
+
|
| 849 |
if __name__ == "__main__":
|
| 850 |
app = App()
|
| 851 |
app.run_gradio()
|
requirements.txt
CHANGED
|
@@ -2,3 +2,5 @@ gradio
|
|
| 2 |
opencv-python
|
| 3 |
flask
|
| 4 |
openai
|
|
|
|
|
|
|
|
|
| 2 |
opencv-python
|
| 3 |
flask
|
| 4 |
openai
|
| 5 |
+
webdriver-manager
|
| 6 |
+
selenium
|