vfs_fetcher.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. # -*- coding: utf-8 -*-
  2. import os
  3. import re
  4. import time
  5. import json
  6. from typing import List, Dict, Any, Optional
  7. from curl_cffi import requests
  8. from app.visa_free_config_fetchers.base_fetcher import BaseFetcher
  9. COUNTRY_INFO = {
  10. "ie": {"iso3": "irl", "name": "Ireland"},
  11. "irl": {"iso3": "irl", "name": "Ireland"},
  12. "gb": {"iso3": "gbr", "name": "United Kingdom"},
  13. "uk": {"iso3": "gb", "name": "United Kingdom"},
  14. "gbr": {"iso3": "gbr", "name": "United Kingdom"},
  15. "cn": {"iso3": "chn", "name": "China"},
  16. "chn": {"iso3": "chn", "name": "China"},
  17. "sg": {"iso3": "sgp", "name": "Singapore"},
  18. "sgp": {"iso3": "sgp", "name": "Singapore"},
  19. "au": {"iso3": "aus", "name": "Australia"},
  20. "aus": {"iso3": "aus", "name": "Australia"},
  21. "nl": {"iso3": "nld", "name": "Netherlands"},
  22. "nld": {"iso3": "nld", "name": "Netherlands"},
  23. "fr": {"iso3": "fra", "name": "France"},
  24. "fra": {"iso3": "fra", "name": "France"},
  25. "it": {"iso3": "ita", "name": "Italy"},
  26. "ita": {"iso3": "ita", "name": "Italy"},
  27. "at": {"iso3": "aut", "name": "Austria"},
  28. "aut": {"iso3": "aut", "name": "Austria"},
  29. "dk": {"iso3": "dnk", "name": "Denmark"},
  30. "dnk": {"iso3": "dnk", "name": "Denmark"},
  31. "fi": {"iso3": "fin", "name": "Finland"},
  32. "fin": {"iso3": "fin", "name": "Finland"},
  33. "hu": {"iso3": "hun", "name": "Hungary"},
  34. "hun": {"iso3": "hun", "name": "Hungary"},
  35. "is": {"iso3": "isl", "name": "Iceland"},
  36. "isl": {"iso3": "isl", "name": "Iceland"},
  37. "no": {"iso3": "nor", "name": "Norway"},
  38. "nor": {"iso3": "nor", "name": "Norway"},
  39. "es": {"iso3": "esp", "name": "Spain"},
  40. "esp": {"iso3": "esp", "name": "Spain"},
  41. "de": {"iso3": "deu", "name": "Germany"},
  42. "deu": {"iso3": "deu", "name": "Germany"},
  43. "gr": {"iso3": "grc", "name": "Greece"},
  44. "grc": {"iso3": "grc", "name": "Greece"},
  45. "be": {"iso3": "bel", "name": "Belgium"},
  46. "bel": {"iso3": "bel", "name": "Belgium"},
  47. "pl": {"iso3": "pol", "name": "Poland"},
  48. "pol": {"iso3": "pol", "name": "Poland"}
  49. }
  50. ISO2_TO_ISO3_API = {
  51. "gb": "gbr",
  52. "uk": "gbr",
  53. "cn": "chn",
  54. "ie": "irl",
  55. "sg": "sgp",
  56. "au": "aus",
  57. "nl": "nld",
  58. "fr": "fra",
  59. "it": "ita",
  60. "at": "aut",
  61. "dk": "dnk",
  62. "fi": "fin",
  63. "hu": "hun",
  64. "is": "isl",
  65. "no": "nor",
  66. "es": "esp",
  67. "de": "deu",
  68. "gr": "grc",
  69. "be": "bel",
  70. "pl": "pol"
  71. }
  72. def _select_candidate_with_llm(candidates: List[Dict[str, Any]], prompt_context: str, session: Optional[requests.Session] = None) -> Dict[str, Any]:
  73. if not candidates:
  74. raise RuntimeError("Empty candidate list passed to LLM selection")
  75. if len(candidates) == 1:
  76. return candidates[0]
  77. api_key = os.environ.get("QWEN_API_KEY") or os.environ.get("DASHSCOPE_API_KEY") or "sk-893e895724c6403d81374e515ffaf427"
  78. url = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
  79. formatted_candidates = "\n".join([f"{i}: {json.dumps(c, ensure_ascii=False)}" for i, c in enumerate(candidates)])
  80. prompt = (
  81. f"上下文要求:{prompt_context}\n"
  82. f"候选列表:\n{formatted_candidates}\n"
  83. f"请判断哪个候选最符合要求。仅输出该候选的索引数字(0-indexed),不要有任何其他文字。"
  84. )
  85. headers = {
  86. "Authorization": f"Bearer {api_key}",
  87. "Content-Type": "application/json"
  88. }
  89. payload = {
  90. "model": "qwen-plus",
  91. "messages": [
  92. {"role": "system", "content": "你是一个配置选择助手。按指令仅输出数字索引。"},
  93. {"role": "user", "content": prompt}
  94. ],
  95. "temperature": 0.0
  96. }
  97. try:
  98. if session is not None:
  99. resp = session.post(url, headers=headers, json=payload, timeout=10)
  100. else:
  101. resp = requests.post(url, headers=headers, json=payload, timeout=10)
  102. if resp.status_code == 200:
  103. content = resp.json()["choices"][0]["message"]["content"].strip()
  104. match = re.search(r"\d+", content)
  105. if match:
  106. idx = int(match.group())
  107. if 0 <= idx < len(candidates):
  108. return candidates[idx]
  109. except Exception as e:
  110. print(f"[WARN] LLM candidate selection failed: {e}")
  111. return candidates[0]
  112. def _vfs_get(url: str, headers: dict, retries: int = 2, session: Optional[requests.Session] = None) -> requests.Response:
  113. for attempt in range(retries + 1):
  114. if session is not None:
  115. resp = session.get(url, headers=headers, timeout=10)
  116. else:
  117. resp = requests.get(url, headers=headers, impersonate="chrome120", timeout=10)
  118. if resp.status_code == 200 or attempt == retries:
  119. return resp
  120. time.sleep(0.5)
  121. return resp
  122. class VFSFetcher(BaseFetcher):
  123. def __init__(self):
  124. super(VFSFetcher, self).__init__("vfs")
  125. def fetch_external_vac_info(self, origin: str, target: str, visa_type: str, vac_code: str, session: Optional[requests.Session] = None) -> Dict[str, Any]:
  126. """
  127. Call external VFS master API using curl-cffi when vac_info is missing from metadata.
  128. Raises RuntimeError if data fetching or matching fails.
  129. """
  130. close_session = False
  131. if session is None:
  132. session = requests.Session(impersonate="chrome120")
  133. close_session = True
  134. try:
  135. origin_clean = origin.lower().strip()
  136. target_clean = target.lower().strip()
  137. vac_clean = vac_code.lower().strip()
  138. visa_clean = visa_type.lower().strip()
  139. origin_info = COUNTRY_INFO.get(origin_clean, {"iso3": origin_clean})
  140. target_info = COUNTRY_INFO.get(target_clean, {"iso3": target_clean})
  141. origin_iso3 = origin_info["iso3"]
  142. target_iso3 = target_info["iso3"]
  143. origin_api_iso3 = ISO2_TO_ISO3_API.get(origin_iso3, origin_iso3)
  144. target_api_iso3 = ISO2_TO_ISO3_API.get(target_iso3, target_iso3)
  145. domain = "lift-apicn.vfsglobal.com" if origin_api_iso3 in ["chn", "cn"] else "lift-api.vfsglobal.com"
  146. headers = {
  147. "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
  148. "Accept": "application/json, text/plain, */*",
  149. "Accept-Language": "en-US,en;q=0.9",
  150. "route": f"{origin_api_iso3}/en/{target_api_iso3}",
  151. "origin": "https://visa.vfsglobal.com",
  152. "referer": "https://visa.vfsglobal.com/"
  153. }
  154. # 1. Fetch Centers
  155. url_center = f"https://{domain}/master/center/{target_api_iso3}/{origin_api_iso3}/en-US"
  156. try:
  157. resp_center = _vfs_get(url_center, headers=headers, session=session)
  158. if resp_center.status_code != 200:
  159. raise RuntimeError(f"VFS master center API returned HTTP {resp_center.status_code}")
  160. centers = resp_center.json()
  161. except Exception as e:
  162. raise RuntimeError(f"Failed to fetch VFS centers for {origin_api_iso3}->{target_api_iso3}: {e}")
  163. if not isinstance(centers, list) or not centers:
  164. raise RuntimeError(f"No centers returned for {origin_api_iso3}->{target_api_iso3}")
  165. # Match center candidate
  166. matched_centers = []
  167. for c in centers:
  168. iso_c = str(c.get("isoCode") or "").lower()
  169. vac_c = str(c.get("vacCode") or "").lower()
  170. name_c = str(c.get("centerName") or "").lower()
  171. city_c = str(c.get("city") or "").lower()
  172. if vac_clean in iso_c or vac_clean in vac_c or vac_clean in name_c or vac_clean in city_c:
  173. matched_centers.append(c)
  174. if not matched_centers:
  175. matched_centers = centers
  176. if len(matched_centers) == 1:
  177. selected_center = matched_centers[0]
  178. else:
  179. selected_center = _select_candidate_with_llm(
  180. matched_centers,
  181. f"选择办理 {origin_api_iso3} 到 {target_api_iso3} 的 VAC 签证中心,目标中心编号/城市: {vac_clean}",
  182. session=session
  183. )
  184. vac_code_fetched = selected_center.get("isoCode") or selected_center.get("vacCode") or vac_code.upper()
  185. center_name_fetched = selected_center.get("centerName", "")
  186. address_fetched = selected_center.get("address", "")
  187. # 2. Fetch Visa Categories
  188. categories = []
  189. url_cat = f"https://{domain}/master/visacategory/{target_api_iso3}/{origin_api_iso3}/{vac_code_fetched}/en-US"
  190. try:
  191. resp_cat = _vfs_get(url_cat, headers=headers, retries=0, session=session)
  192. if resp_cat.status_code == 200 and isinstance(resp_cat.json(), list) and resp_cat.json():
  193. categories = resp_cat.json()
  194. except Exception as e:
  195. print(f"[INFO] Primary center visacategory query failed for {vac_code_fetched}: {e}")
  196. if not categories:
  197. for alt_center in centers:
  198. alt_code = alt_center.get("isoCode") or alt_center.get("vacCode")
  199. if not alt_code or alt_code == vac_code_fetched:
  200. continue
  201. try:
  202. alt_url_cat = f"https://{domain}/master/visacategory/{target_api_iso3}/{origin_api_iso3}/{alt_code}/en-US"
  203. resp_alt_cat = _vfs_get(alt_url_cat, headers=headers, retries=0, session=session)
  204. if resp_alt_cat.status_code == 200 and isinstance(resp_alt_cat.json(), list) and resp_alt_cat.json():
  205. categories = resp_alt_cat.json()
  206. break
  207. except Exception as e:
  208. continue
  209. if not categories:
  210. categories = [{
  211. "visaCategoryCode": "Short Stay Visa",
  212. "code": "Short Stay Visa",
  213. "name": "Short Stay Visa"
  214. }]
  215. if len(categories) == 1:
  216. selected_cat = categories[0]
  217. else:
  218. matched_cats = [
  219. cat for cat in categories
  220. if visa_clean in str(cat.get("name", "")).lower() or visa_clean in str(cat.get("code", "")).lower()
  221. ]
  222. if len(matched_cats) == 1:
  223. selected_cat = matched_cats[0]
  224. elif len(matched_cats) > 1:
  225. selected_cat = _select_candidate_with_llm(
  226. matched_cats,
  227. f"选择符合签证类型 '{visa_clean}' 的 Visa Category 类别",
  228. session=session
  229. )
  230. else:
  231. selected_cat = _select_candidate_with_llm(
  232. categories,
  233. f"选择符合签证类型 '{visa_clean}' 的 Visa Category 类别",
  234. session=session
  235. )
  236. cat_code_fetched = selected_cat.get("visaCategoryCode") or selected_cat.get("code", "")
  237. cat_name_fetched = selected_cat.get("name", "")
  238. # 3. Fetch Visa Subcategories (Note: unauthenticated subvisacategory API may return HTTP 500 on VFS backend)
  239. url_sub = f"https://{domain}/master/subvisacategory/{target_api_iso3}/{origin_api_iso3}/{vac_code_fetched}/{cat_code_fetched}/en-US"
  240. subcat_code_fetched = "To"
  241. subcat_name_fetched = "Tourist"
  242. try:
  243. resp_sub = _vfs_get(url_sub, headers=headers, retries=0, session=session)
  244. if resp_sub.status_code == 200 and isinstance(resp_sub.json(), list) and resp_sub.json():
  245. subcategories = resp_sub.json()
  246. if len(subcategories) == 1:
  247. selected_sub = subcategories[0]
  248. else:
  249. selected_sub = _select_candidate_with_llm(
  250. subcategories,
  251. f"选择符合签证类型 '{visa_clean}' 的 Subvisa Category 子类别",
  252. session=session
  253. )
  254. subcat_code_fetched = selected_sub.get("subCategoryCode") or selected_sub.get("code", "To")
  255. subcat_name_fetched = selected_sub.get("subCategoryName") or selected_sub.get("name", "Tourist")
  256. except Exception as e:
  257. print(f"[INFO] Subvisacategory query unauthenticated fallback: {e}")
  258. return {
  259. "routing_key": f"slot.{vac_clean}.{target_clean}.{visa_clean}",
  260. "vac_code": vac_code_fetched,
  261. "center_name": center_name_fetched,
  262. "address": address_fetched,
  263. "category_code": cat_code_fetched,
  264. "category_name": cat_name_fetched,
  265. "subcategory_code": subcat_code_fetched,
  266. "subcategory_name": subcat_name_fetched
  267. }
  268. finally:
  269. if close_session:
  270. session.close()
  271. def fetch_free_config(self, origin, target, visa_type, vac_code=None, session: Optional[requests.Session] = None):
  272. origin_clean = origin.lower().strip()
  273. target_clean = target.lower().strip()
  274. vac_clean = (vac_code or "").lower().strip()
  275. visa_clean = visa_type.lower().strip()
  276. origin_info = COUNTRY_INFO.get(origin_clean, {"iso3": origin_clean, "name": origin.capitalize()})
  277. target_info = COUNTRY_INFO.get(target_clean, {"iso3": target_clean, "name": target.capitalize()})
  278. free_config = {
  279. "mission_code": target_info["iso3"],
  280. "mission_name": target_info["name"],
  281. "country_code": origin_info["iso3"],
  282. "country_name": origin_info["name"],
  283. "culture_code": "en-US",
  284. "language": "en",
  285. "apt_configs": {}
  286. }
  287. vacs = self.metadata.get("vacs", {})
  288. vac_key = "{}_{}_{}_{}".format(origin_clean, target_clean, vac_clean, visa_clean)
  289. vac_info = vacs.get(vac_key, {})
  290. if not vac_info and vac_clean:
  291. for k, v in vacs.items():
  292. if k.startswith("{}_{}_{}".format(origin_clean, target_clean, vac_clean)):
  293. vac_info = v
  294. break
  295. # If vac_info NOT found in metadata, call external tool / API online!
  296. if not vac_info and vac_clean:
  297. vac_info = self.fetch_external_vac_info(origin_clean, target_clean, visa_clean, vac_clean, session=session)
  298. if vac_info or vac_clean:
  299. routing_key = vac_info.get("routing_key", "slot.{}.{}.{}".format(vac_clean, target_clean, visa_clean))
  300. free_config["apt_configs"][routing_key] = {
  301. "center_name": vac_info.get("center_name", ""),
  302. "address": vac_info.get("address", ""),
  303. "vac_code": vac_info.get("vac_code", ""),
  304. "category_name": vac_info.get("category_name", ""),
  305. "category_code": vac_info.get("category_code", ""),
  306. "subcategory_name": vac_info.get("subcategory_name", ""),
  307. "subcategory_code": vac_info.get("subcategory_code", "")
  308. }
  309. return free_config