troov_service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. import json
  2. import time
  3. import random
  4. import asyncio
  5. import aiohttp
  6. from datetime import datetime, timedelta
  7. from typing import List, Optional, Tuple, Dict, Any
  8. from redis.asyncio import Redis
  9. from starlette.concurrency import run_in_threadpool
  10. from app.core.biz_exception import NotFoundError, BizLogicError
  11. from app.schemas.troov import TroovRate, TroovProb, TroovCheckForbiddenInput
  12. from app.utils.france_slot_api import troov_create_session_old
  13. from app.utils.proxy_utils import load_proxies_from_json
  14. from app.core.logger import logger
  15. class TroovService:
  16. """
  17. Troov 业务逻辑工具类 (Pure Static)
  18. """
  19. # Redis Lua Script for atomic token retrieval
  20. POP_TOKEN_LUA = """
  21. local cursor = "0"
  22. local max_ttl = -1
  23. local max_key = nil
  24. repeat
  25. local result = redis.call('SCAN', cursor, 'MATCH', 'token:*', 'COUNT', 50)
  26. cursor = result[1]
  27. local keys = result[2]
  28. for _, key in ipairs(keys) do
  29. local ttl = redis.call('TTL', key)
  30. if ttl > max_ttl then max_ttl = ttl; max_key = key end
  31. end
  32. until cursor == "0"
  33. if max_key then
  34. local value = redis.call('GET', max_key)
  35. redis.call('DEL', max_key)
  36. return {max_key, value, max_ttl}
  37. end
  38. return nil
  39. """
  40. # Static configuration data for payload (Moved from function to class constant)
  41. _ZONE_DATA = {
  42. "name": 'Visas',
  43. "name_traduction": {"fr": 'Visas', "en": "", "zh": "", "ar": "", "ru": "", "it": "", "es": "", "de": "", "pt": ""},
  44. "enable_external_url": False,
  45. "external_url": "",
  46. "has_paid_reservation": False,
  47. "openings": [
  48. {"day": 1, "begin_h": 9, "begin_m": 0, "end_h": 13, "end_m": 0, "_id": "65b969289175b1f087bdf357"},
  49. {"day": 2, "begin_h": 9, "begin_m": 0, "end_h": 13, "end_m": 0, "_id": "65b969289175b1f087bdf358"},
  50. {"day": 3, "begin_h": 9, "begin_m": 0, "end_h": 13, "end_m": 0, "_id": "65b969289175b1f087bdf359"},
  51. {"day": 4, "begin_h": 9, "begin_m": 0, "end_h": 13, "end_m": 0, "_id": "65b969289175b1f087bdf35a"},
  52. {"day": 5, "begin_h": 9, "begin_m": 0, "end_h": 13, "end_m": 0, "_id": "65b969289175b1f087bdf35b"}
  53. ],
  54. "custom_openings": [],
  55. "breaktimes": [
  56. [
  57. {"day": 1, "begin_h": 12, "begin_m": 0, "end_h": 14, "end_m": 0, "_id": "65e1bb30ec8f214f6a5af678"},
  58. {"day": 2, "begin_h": 12, "begin_m": 0, "end_h": 14, "end_m": 0, "_id": "65e1bb30ec8f214f6a5af679"},
  59. {"day": 3, "begin_h": 12, "begin_m": 0, "end_h": 14, "end_m": 0, "_id": "65e1bb30ec8f214f6a5af67a"},
  60. {"day": 4, "begin_h": 12, "begin_m": 0, "end_h": 14, "end_m": 0, "_id": "65e1bb30ec8f214f6a5af67b"},
  61. {"day": 5, "begin_h": 12, "begin_m": 0, "end_h": 14, "end_m": 0, "_id": "65e1bb30ec8f214f6a5af67c"}
  62. ],
  63. []
  64. ],
  65. "session_duration": 15, "session_type": "people", "session_reservation_max": 1000,
  66. "session_people_max": 1, "reservation_people_max": 1, "is_priority": True,
  67. "reservation_delay_hours": 0, "start_opening": "2022-03-31", "end_opening": "2025-12-31",
  68. "is_open": True, "is_open_internal": True, "stand_alone_calendar": False,
  69. "note": {"ar": "", "de": "", "en": "", "es": "", "fr": "", "it": "", "nl": "", "pt": "", "ru": "", "zh": ""},
  70. "dynamic_calendar_enabled": True,
  71. "dynamic_calendar_ending": {"hour": "default", "minute": "default"},
  72. "external_link_for_documents": "https://france-visas.gouv.fr/en/web/france-visas/online-application",
  73. "dynamic_calendar": {"begin": {"type": "days"}, "end": {"type": "days", "value": 7}},
  74. "closed_days": [
  75. "2024-01-01", "2024-02-05", "2024-03-18", "2024-03-22", "2024-03-23", "2024-03-24", "2024-03-25",
  76. "2024-03-26", "2024-03-27", "2024-03-28", "2024-03-29", "2024-04-01", "2024-05-01", "2024-05-06",
  77. "2024-05-09", "2024-06-03", "2024-08-05", "2024-10-28", "2024-12-25", "2024-12-26", "2025-01-01",
  78. "2025-02-03", "2025-03-17", "2025-04-21", "2025-05-05", "2025-06-02", "2025-08-04", "2025-08-15",
  79. "2025-10-27", "2025-12-25", "2025-12-26"
  80. ],
  81. "custom_fields": [], "service_color": "#e91e63", "enable_repeat_form": False,
  82. "enable_fullday_slots": False, "session_price": 0, "cancel_limit": {"value": 0, "type": "days"},
  83. "activate_waiting_list": False, "deactivate_reservation_cancelation": True,
  84. "_id": '624317926863643fe83c8548'
  85. }
  86. _probability_model = 'probability_model'
  87. _time_slots_am = [
  88. "09:30", "09:45", "10:00", "10:15", "10:30",
  89. "10:45", "11:00", "11:15", "11:30", "11:45"
  90. ]
  91. _time_slots_pm = ["14:00", "14:15", "14:30", "14:45", "15:00"]
  92. # =========================================================
  93. # Public Methods
  94. # =========================================================
  95. @staticmethod
  96. async def check_for_forbiddenusers(redis_client: Redis, payload: TroovCheckForbiddenInput) -> Dict[str, Any]:
  97. """检测用户是否被禁止"""
  98. current_proxy, session_dic = await TroovService._prepare_session_context(redis_client)
  99. booking_token = await TroovService._get_valid_token(redis_client)
  100. if not booking_token:
  101. raise NotFoundError(message="Failed to retrieve second captcha token for booking")
  102. # Inline logic: Calculate next Monday
  103. today = datetime.today()
  104. days_ahead = 7 - today.weekday()
  105. if days_ahead == 0: days_ahead = 7
  106. date = (today + timedelta(days=days_ahead)).strftime("%Y-%m-%d")
  107. slot = {'time': '09:30', 'rate': '0.00', 'capacity': 1}
  108. book_uinfo = {
  109. "id": 0,
  110. "birth_date": payload.birthday.strftime("%m/%d/%Y"),
  111. "email": 'arket_zz@163.com',
  112. "phone": '+3530829394212',
  113. "first_name": payload.first_name,
  114. "last_name": payload.last_name,
  115. }
  116. # Build Payload inline
  117. book_body = TroovService._build_book_payload(session_dic['session_id'], date, slot, book_uinfo, booking_token)
  118. # Exec Request
  119. url = f"https://51.254.177.49/api/team/{session_dic['embassy']['teamId']}/reservations/family"
  120. headers = {
  121. 'accept': 'application/json, text/plain, */*',
  122. 'content-type': 'application/json',
  123. 'origin': 'https://consulat.gouv.fr',
  124. 'referer': session_dic['embassy']['website'],
  125. "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
  126. 'x-csrf-token': session_dic['x-csrf-token'],
  127. 'x-gouv-app-id': session_dic['x_gouv_app_id'],
  128. 'x-gouv-web': 'fr.gouv.consulat',
  129. }
  130. async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), connector=aiohttp.TCPConnector(ssl=False)) as session:
  131. async with session.post(url, headers=headers, data=json.dumps(book_body), proxy=current_proxy) as resp:
  132. return json.loads(await resp.text())
  133. @staticmethod
  134. async def get_rate_by_date(redis_client: Redis, date: str) -> List[TroovRate]:
  135. """根据日期获取预约可用性"""
  136. current_proxy, session_dic = await TroovService._prepare_session_context(redis_client)
  137. url = "https://51.254.177.49/api/team/621540d353069dec25bd0045/reservations/availability"
  138. params = {
  139. "name": "Visas", "date": date, "places": "-5", "matching": "",
  140. "maxCapacity": "-5", "sessionId": session_dic.get("session_id")
  141. }
  142. headers = {
  143. "accept": "application/json, text/plain, */*",
  144. "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
  145. "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36",
  146. "x-gouv-app-id": session_dic.get("x_gouv_app_id"),
  147. "x-gouv-web": "fr.gouv.consulat",
  148. }
  149. async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=15), connector=aiohttp.TCPConnector(ssl=False)) as session:
  150. async with session.get(url, params=params, headers=headers, proxy=current_proxy) as resp:
  151. resp.raise_for_status()
  152. return json.loads(await resp.text())
  153. # =========================================================
  154. # Internal Logic
  155. # =========================================================
  156. @staticmethod
  157. async def _prepare_session_context(redis_client: Redis) -> Tuple[str, Dict[str, Any]]:
  158. """获取代理 -> 获取Token -> 创建Session"""
  159. # Inline proxy loading
  160. proxies = []
  161. for pool in ("oxylabs",):
  162. proxies.extend(load_proxies_from_json("data/proxy_pool_config.json", pool))
  163. if not proxies:
  164. raise NotFoundError(message="Proxy pool is empty")
  165. current_proxy = random.choice(proxies)
  166. captcha_token = await TroovService._get_valid_token(redis_client)
  167. if not captcha_token:
  168. raise NotFoundError(message="Failed to retrieve captcha token")
  169. logger.info(f"Creating session with proxy: {current_proxy}...")
  170. session_dic = await run_in_threadpool(troov_create_session_old, current_proxy, captcha_token)
  171. if not session_dic:
  172. raise BizLogicError(message="Failed to create Troov session")
  173. logger.info(f"Troov session created: {session_dic.get('session_id')}")
  174. return current_proxy, session_dic
  175. @staticmethod
  176. async def get_all_probs(redis_client: Redis) -> List[TroovProb]:
  177. prob_map = await redis_client.hgetall(TroovService._probability_model)
  178. res = []
  179. for k, v in prob_map.items():
  180. # redis 返回 bytes
  181. key_str = k.decode() if isinstance(k, (bytes, bytearray)) else k
  182. val_str = v.decode() if isinstance(v, (bytes, bytearray)) else v
  183. res.append(
  184. TroovProb(
  185. prob_key=datetime.strptime(key_str, "%Y-%m-%d.%H:%M"),
  186. prob_val=float(val_str) if val_str is not None else None,
  187. )
  188. )
  189. # 按时间排序(强烈建议)
  190. res.sort(key=lambda x: x.prob_key)
  191. return res
  192. @staticmethod
  193. async def set_prob(redis_client: Redis, payload: TroovProb):
  194. """更新 / 新增概率"""
  195. slot_key = payload.prob_key.strftime("%Y-%m-%d.%H:%M")
  196. prob_val = payload.prob_val
  197. if prob_val is not None:
  198. prob_val = max(0.0, min(1.0, prob_val))
  199. await redis_client.hset(
  200. TroovService._probability_model,
  201. slot_key,
  202. prob_val,
  203. )
  204. return await TroovService.get_all_probs(redis_client)
  205. @staticmethod
  206. async def del_prob(redis_client: Redis, payload: TroovProb):
  207. """移除单个 slot 概率"""
  208. slot_key = payload.prob_key.strftime("%Y-%m-%d.%H:%M")
  209. await redis_client.hdel(
  210. TroovService._probability_model,
  211. slot_key,
  212. )
  213. return await TroovService.get_all_probs(redis_client)
  214. @staticmethod
  215. async def reset_probs(redis_client: Redis, date: str):
  216. """
  217. 重置某一天的所有时间槽概率
  218. 周六周日不生成
  219. 周五只有上午
  220. """
  221. await redis_client.delete(TroovService._probability_model)
  222. date_dt = datetime.strptime(date, "%Y-%m-%d")
  223. # 周六、周日
  224. if date_dt.weekday() in (5, 6):
  225. return []
  226. if date_dt.weekday() == 4: # 周五
  227. timeslots = TroovService._time_slots_am
  228. else:
  229. timeslots = TroovService._time_slots_am + TroovService._time_slots_pm
  230. mapping = {
  231. f"{date}.{time_slot}": 0.5
  232. for time_slot in timeslots
  233. }
  234. if mapping:
  235. await redis_client.hset(
  236. TroovService._probability_model,
  237. mapping=mapping,
  238. )
  239. return await TroovService.get_all_probs(redis_client)
  240. @staticmethod
  241. async def _get_valid_token(redis_client: Redis, timeout: int = 30) -> Optional[str]:
  242. start_time = time.time()
  243. while time.time() - start_time < timeout:
  244. result = await redis_client.eval(TroovService.POP_TOKEN_LUA, 0)
  245. if result:
  246. try:
  247. return json.loads(result[1]).get("token")
  248. except:
  249. logger.warning("Invalid token format in Redis")
  250. await asyncio.sleep(1)
  251. return None
  252. @staticmethod
  253. def _build_book_payload(sid, date, slot, uinfo, token):
  254. """构造预约 Payload (原 troov_dublin_visas_book_data_builder,逻辑已内联)"""
  255. # Parse Dates Inline
  256. birth_parts = uinfo['birth_date'].split('/')
  257. dt_obj = datetime.strptime(date, "%Y-%m-%d")
  258. slot_h, slot_m = slot['time'].split(":")
  259. # Formatter logic inline
  260. fmt_date_full = dt_obj.strftime("%Y-%m-%dT%H:%M:%S.000Z")
  261. fmt_tz = datetime.strptime(slot['time'], "%H:%M").strftime("-%H:%M")
  262. formatted_slot_datetime = f"{fmt_date_full}{fmt_tz}"
  263. slot_value_str = f"slot-visas-{formatted_slot_datetime}"
  264. # Build Slot Object Inline
  265. slot_obj = {
  266. 'time': slot['time'], 'rate': slot['rate'], 'capacity': slot['capacity'],
  267. 'numberOfApplicants': 1,
  268. "date": f"{date}T{slot['time']}:00",
  269. "localDateString": dt_obj.strftime("%m/%d/%Y"),
  270. "dateObject": {
  271. "year": dt_obj.year, "month": dt_obj.month - 1, "day": dt_obj.day,
  272. "hour": slot_h, "minute": slot_m,
  273. },
  274. "id": 0, "slotValue": slot_value_str
  275. }
  276. return {
  277. "reservations": {
  278. "mainUser": {
  279. "lastname": uinfo['last_name'], "firstname": uinfo['first_name'],
  280. "email": uinfo['email'], "mobile": uinfo['phone'],
  281. "birthdate": {"month": int(birth_parts[0]) - 1, "day": int(birth_parts[1]), "year": int(birth_parts[2])},
  282. "slots": {},
  283. "services": [
  284. {
  285. "zone": TroovService._ZONE_DATA,
  286. "zone_id": '624317926863643fe83c8548',
  287. "external_link_for_documents": "https://france-visas.gouv.fr/en/web/france-visas/online-application",
  288. "label": 'Visas', "name": 'Visas', "numberOfSlots": 1, "maxSlots": 5,
  289. "checkboxesSlots": [slot_value_str], "customFields": [], "customFieldsAreValid": True,
  290. "slots": [slot_obj], "slotsToKeep": [slot_obj]
  291. }
  292. ]
  293. },
  294. "secondaryUsers": [], "sessionId": sid, "team": "621540d353069dec25bd0045"
  295. },
  296. "language": "en", "captcha": token, "sessionId": sid
  297. }