This commit is contained in:
HuangHai
2026-01-12 07:49:18 +08:00
commit b66f683dfb
2891 changed files with 21165 additions and 0 deletions

8
.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

14
.idea/Crawler.iml generated Normal file
View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="jdk" jdkName="D:\anaconda3\envs\py310" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

View File

@@ -0,0 +1,196 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
<option name="ignoredPackages">
<value>
<list size="182">
<item index="0" class="java.lang.String" itemvalue="httpx" />
<item index="1" class="java.lang.String" itemvalue="gensim" />
<item index="2" class="java.lang.String" itemvalue="numba" />
<item index="3" class="java.lang.String" itemvalue="tzlocal" />
<item index="4" class="java.lang.String" itemvalue="greenlet" />
<item index="5" class="java.lang.String" itemvalue="PyYAML" />
<item index="6" class="java.lang.String" itemvalue="psycopg2" />
<item index="7" class="java.lang.String" itemvalue="py" />
<item index="8" class="java.lang.String" itemvalue="pycparser" />
<item index="9" class="java.lang.String" itemvalue="redis" />
<item index="10" class="java.lang.String" itemvalue="elasticsearch" />
<item index="11" class="java.lang.String" itemvalue="volcengine" />
<item index="12" class="java.lang.String" itemvalue="aliyun-python-sdk-core" />
<item index="13" class="java.lang.String" itemvalue="pycryptodomex" />
<item index="14" class="java.lang.String" itemvalue="starlette" />
<item index="15" class="java.lang.String" itemvalue="bleach" />
<item index="16" class="java.lang.String" itemvalue="lxml" />
<item index="17" class="java.lang.String" itemvalue="pyreadline3" />
<item index="18" class="java.lang.String" itemvalue="soupsieve" />
<item index="19" class="java.lang.String" itemvalue="et_xmlfile" />
<item index="20" class="java.lang.String" itemvalue="esdk-obs-python" />
<item index="21" class="java.lang.String" itemvalue="json_repair" />
<item index="22" class="java.lang.String" itemvalue="nano-vectordb" />
<item index="23" class="java.lang.String" itemvalue="fuzzywuzzy" />
<item index="24" class="java.lang.String" itemvalue="jsonschema" />
<item index="25" class="java.lang.String" itemvalue="passlib" />
<item index="26" class="java.lang.String" itemvalue="xlrd" />
<item index="27" class="java.lang.String" itemvalue="darabonba-core" />
<item index="28" class="java.lang.String" itemvalue="pywin32" />
<item index="29" class="java.lang.String" itemvalue="pydantic" />
<item index="30" class="java.lang.String" itemvalue="zai" />
<item index="31" class="java.lang.String" itemvalue="alibabacloud_tea_util" />
<item index="32" class="java.lang.String" itemvalue="lightrag-hku" />
<item index="33" class="java.lang.String" itemvalue="rembg" />
<item index="34" class="java.lang.String" itemvalue="google" />
<item index="35" class="java.lang.String" itemvalue="pipmaster" />
<item index="36" class="java.lang.String" itemvalue="click" />
<item index="37" class="java.lang.String" itemvalue="attrs" />
<item index="38" class="java.lang.String" itemvalue="contourpy" />
<item index="39" class="java.lang.String" itemvalue="openai" />
<item index="40" class="java.lang.String" itemvalue="flatbuffers" />
<item index="41" class="java.lang.String" itemvalue="pdfminer.six" />
<item index="42" class="java.lang.String" itemvalue="regex" />
<item index="43" class="java.lang.String" itemvalue="WMI" />
<item index="44" class="java.lang.String" itemvalue="pydantic_core" />
<item index="45" class="java.lang.String" itemvalue="aioredis" />
<item index="46" class="java.lang.String" itemvalue="imageio" />
<item index="47" class="java.lang.String" itemvalue="platformdirs" />
<item index="48" class="java.lang.String" itemvalue="propcache" />
<item index="49" class="java.lang.String" itemvalue="matplotlib" />
<item index="50" class="java.lang.String" itemvalue="oss2" />
<item index="51" class="java.lang.String" itemvalue="PyMySQL" />
<item index="52" class="java.lang.String" itemvalue="httpcore" />
<item index="53" class="java.lang.String" itemvalue="idna" />
<item index="54" class="java.lang.String" itemvalue="referencing" />
<item index="55" class="java.lang.String" itemvalue="rsa" />
<item index="56" class="java.lang.String" itemvalue="alibabacloud_tea_fileform" />
<item index="57" class="java.lang.String" itemvalue="decorator" />
<item index="58" class="java.lang.String" itemvalue="langchain-openai" />
<item index="59" class="java.lang.String" itemvalue="networkx" />
<item index="60" class="java.lang.String" itemvalue="xfyunsdkspeech" />
<item index="61" class="java.lang.String" itemvalue="zhipuai" />
<item index="62" class="java.lang.String" itemvalue="Levenshtein" />
<item index="63" class="java.lang.String" itemvalue="numpy" />
<item index="64" class="java.lang.String" itemvalue="pyasn1" />
<item index="65" class="java.lang.String" itemvalue="requests" />
<item index="66" class="java.lang.String" itemvalue="bitarray" />
<item index="67" class="java.lang.String" itemvalue="opencv-python-headless" />
<item index="68" class="java.lang.String" itemvalue="sniffio" />
<item index="69" class="java.lang.String" itemvalue="exceptiongroup" />
<item index="70" class="java.lang.String" itemvalue="alibabacloud-docmind-api20220711" />
<item index="71" class="java.lang.String" itemvalue="tenacity" />
<item index="72" class="java.lang.String" itemvalue="jsonpath" />
<item index="73" class="java.lang.String" itemvalue="alibabacloud-credentials-api" />
<item index="74" class="java.lang.String" itemvalue="smart_open" />
<item index="75" class="java.lang.String" itemvalue="volcengine-python-sdk" />
<item index="76" class="java.lang.String" itemvalue="websockets" />
<item index="77" class="java.lang.String" itemvalue="annotated-types" />
<item index="78" class="java.lang.String" itemvalue="lazy_loader" />
<item index="79" class="java.lang.String" itemvalue="scipy" />
<item index="80" class="java.lang.String" itemvalue="pyasn1_modules" />
<item index="81" class="java.lang.String" itemvalue="aiofiles" />
<item index="82" class="java.lang.String" itemvalue="jsonpointer" />
<item index="83" class="java.lang.String" itemvalue="python-multipart" />
<item index="84" class="java.lang.String" itemvalue="pandas" />
<item index="85" class="java.lang.String" itemvalue="xfyunsdkcore" />
<item index="86" class="java.lang.String" itemvalue="pdfplumber" />
<item index="87" class="java.lang.String" itemvalue="future" />
<item index="88" class="java.lang.String" itemvalue="dashscope" />
<item index="89" class="java.lang.String" itemvalue="typing_extensions" />
<item index="90" class="java.lang.String" itemvalue="cachetools" />
<item index="91" class="java.lang.String" itemvalue="langchain-core" />
<item index="92" class="java.lang.String" itemvalue="multidict" />
<item index="93" class="java.lang.String" itemvalue="yarl" />
<item index="94" class="java.lang.String" itemvalue="pycryptodome" />
<item index="95" class="java.lang.String" itemvalue="pytz" />
<item index="96" class="java.lang.String" itemvalue="tiktoken" />
<item index="97" class="java.lang.String" itemvalue="protobuf" />
<item index="98" class="java.lang.String" itemvalue="alibabacloud_openapi_util" />
<item index="99" class="java.lang.String" itemvalue="jiter" />
<item index="100" class="java.lang.String" itemvalue="alibabacloud_endpoint_util" />
<item index="101" class="java.lang.String" itemvalue="python-dotenv" />
<item index="102" class="java.lang.String" itemvalue="h11" />
<item index="103" class="java.lang.String" itemvalue="pyuca" />
<item index="104" class="java.lang.String" itemvalue="cycler" />
<item index="105" class="java.lang.String" itemvalue="MarkupSafe" />
<item index="106" class="java.lang.String" itemvalue="sse-starlette" />
<item index="107" class="java.lang.String" itemvalue="RapidFuzz" />
<item index="108" class="java.lang.String" itemvalue="tinycss2" />
<item index="109" class="java.lang.String" itemvalue="frozenlist" />
<item index="110" class="java.lang.String" itemvalue="ascii_colors" />
<item index="111" class="java.lang.String" itemvalue="alibabacloud-credentials" />
<item index="112" class="java.lang.String" itemvalue="alibabacloud-ocr-api20210707" />
<item index="113" class="java.lang.String" itemvalue="captcha" />
<item index="114" class="java.lang.String" itemvalue="anyio" />
<item index="115" class="java.lang.String" itemvalue="google-genai" />
<item index="116" class="java.lang.String" itemvalue="elastic-transport" />
<item index="117" class="java.lang.String" itemvalue="pyparsing" />
<item index="118" class="java.lang.String" itemvalue="pypinyin" />
<item index="119" class="java.lang.String" itemvalue="sox" />
<item index="120" class="java.lang.String" itemvalue="sympy" />
<item index="121" class="java.lang.String" itemvalue="beautifulsoup4" />
<item index="122" class="java.lang.String" itemvalue="tifffile" />
<item index="123" class="java.lang.String" itemvalue="wrapt" />
<item index="124" class="java.lang.String" itemvalue="aiohappyeyeballs" />
<item index="125" class="java.lang.String" itemvalue="cryptography" />
<item index="126" class="java.lang.String" itemvalue="kiwisolver" />
<item index="127" class="java.lang.String" itemvalue="orjson" />
<item index="128" class="java.lang.String" itemvalue="APScheduler" />
<item index="129" class="java.lang.String" itemvalue="coloredlogs" />
<item index="130" class="java.lang.String" itemvalue="alibabacloud-tea" />
<item index="131" class="java.lang.String" itemvalue="fonttools" />
<item index="132" class="java.lang.String" itemvalue="alibabacloud_tea_xml" />
<item index="133" class="java.lang.String" itemvalue="alibabacloud_oss_sdk" />
<item index="134" class="java.lang.String" itemvalue="configparser" />
<item index="135" class="java.lang.String" itemvalue="charset-normalizer" />
<item index="136" class="java.lang.String" itemvalue="gmssl" />
<item index="137" class="java.lang.String" itemvalue="langsmith" />
<item index="138" class="java.lang.String" itemvalue="scikit-image" />
<item index="139" class="java.lang.String" itemvalue="jieba" />
<item index="140" class="java.lang.String" itemvalue="async-timeout" />
<item index="141" class="java.lang.String" itemvalue="python-Levenshtein" />
<item index="142" class="java.lang.String" itemvalue="alibabacloud-openplatform20191219" />
<item index="143" class="java.lang.String" itemvalue="jwt" />
<item index="144" class="java.lang.String" itemvalue="SQLAlchemy" />
<item index="145" class="java.lang.String" itemvalue="wcwidth" />
<item index="146" class="java.lang.String" itemvalue="crcmod" />
<item index="147" class="java.lang.String" itemvalue="PyMatting" />
<item index="148" class="java.lang.String" itemvalue="llvmlite" />
<item index="149" class="java.lang.String" itemvalue="Jinja2" />
<item index="150" class="java.lang.String" itemvalue="typing-inspection" />
<item index="151" class="java.lang.String" itemvalue="jsonschema-specifications" />
<item index="152" class="java.lang.String" itemvalue="onnxruntime" />
<item index="153" class="java.lang.String" itemvalue="rpds-py" />
<item index="154" class="java.lang.String" itemvalue="pypdfium2" />
<item index="155" class="java.lang.String" itemvalue="urllib3" />
<item index="156" class="java.lang.String" itemvalue="python-jose" />
<item index="157" class="java.lang.String" itemvalue="retry" />
<item index="158" class="java.lang.String" itemvalue="alibabacloud_oss_util" />
<item index="159" class="java.lang.String" itemvalue="python-docx" />
<item index="160" class="java.lang.String" itemvalue="six" />
<item index="161" class="java.lang.String" itemvalue="bitstring" />
<item index="162" class="java.lang.String" itemvalue="tzdata" />
<item index="163" class="java.lang.String" itemvalue="ecdsa" />
<item index="164" class="java.lang.String" itemvalue="jsonpath-rw" />
<item index="165" class="java.lang.String" itemvalue="packaging" />
<item index="166" class="java.lang.String" itemvalue="pooch" />
<item index="167" class="java.lang.String" itemvalue="xlsxwriter" />
<item index="168" class="java.lang.String" itemvalue="alibabacloud-tea-openapi" />
<item index="169" class="java.lang.String" itemvalue="humanfriendly" />
<item index="170" class="java.lang.String" itemvalue="zstandard" />
<item index="171" class="java.lang.String" itemvalue="alibabacloud_gateway_spi" />
<item index="172" class="java.lang.String" itemvalue="chardet" />
<item index="173" class="java.lang.String" itemvalue="jmespath" />
<item index="174" class="java.lang.String" itemvalue="tqdm" />
<item index="175" class="java.lang.String" itemvalue="dotenv" />
<item index="176" class="java.lang.String" itemvalue="aliyun-python-sdk-kms" />
<item index="177" class="java.lang.String" itemvalue="pillow" />
<item index="178" class="java.lang.String" itemvalue="aiohttp" />
<item index="179" class="java.lang.String" itemvalue="langchain-text-splitters" />
<item index="180" class="java.lang.String" itemvalue="aiosignal" />
<item index="181" class="java.lang.String" itemvalue="google-auth" />
</list>
</value>
</option>
</inspection_tool>
</profile>
</component>

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

7
.idea/misc.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Black">
<option name="sdkName" value="Python 3.12" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="D:\anaconda3\envs\py310" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/Crawler.iml" filepath="$PROJECT_DIR$/.idea/Crawler.iml" />
</modules>
</component>
</project>

7
.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/../.." vcs="Git" />
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

View File

@@ -0,0 +1,888 @@
"""
高德地图高精度坐标获取工具
使用说明:
1、注册用户,实名认证
2、手动登录,选择微信登录,生成cookie
3、复用这个cookie,打开浏览器,传入单位名称,查询坐标,得到小数点6位的坐标数据
"""
import json
import os
import random
import re
import sys
import time
import weakref
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
# 添加SQLAlchemy同步操作
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# 数据库连接配置
from Config.Config import POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DATABASE
# 创建同步数据库连接URL
db_url = f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_HOST}:{POSTGRES_PORT}/{POSTGRES_DATABASE}"
# 创建数据库引擎和会话工厂
engine = create_engine(db_url)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
# 数据库操作函数
def find(sql, params=None):
"""
执行SQL查询,返回所有结果
Args:
sql: SQL查询语句
params: 参数字典
Returns:
list: 查询结果的字典列表
"""
with SessionLocal() as session:
result = session.execute(text(sql), params)
rows = result.fetchall()
return [dict(zip(result.keys(), row)) for row in rows]
def findFirst(sql, params=None):
"""
执行SQL查询,返回第一条结果
Args:
sql: SQL查询语句
params: 参数字典
Returns:
dict or None: 第一条查询结果的字典,或None(如果没有结果)
"""
with SessionLocal() as session:
result = session.execute(text(sql), params)
row = result.fetchone()
if row:
return dict(zip(result.keys(), row))
return None
def execute_update(sql, params=None):
"""
执行SQL更新操作
Args:
sql: SQL语句
params: 参数字典
Returns:
int: 受影响的行数
"""
with SessionLocal() as session:
with session.begin():
result = session.execute(text(sql), params)
return result.rowcount
# 是否使用无头模式运行(True=无头模式,False=有界面模式)
HEADLESS = False
# 最大重试次数
MAX_RETRIES = 1
# WebDriver超时设置(秒)
TIMEOUT_SHORT = 5
TIMEOUT_MEDIUM = 10
TIMEOUT_LONG = 15
def parse_coordinates_info(text):
"""解析坐标信息,提取第一个搜索结果的坐标"""
# 检查输入参数
if not text:
return None
try:
# 清理文本,移除可能的干扰字符
clean_text = re.sub(r'[\u3000\t\n\r ]+', ',', text) # 替换空格和制表符为逗号
clean_text = re.sub(r'[,,]+', ',', clean_text) # 统一使用英文逗号
clean_text = clean_text.strip(',') # 移除首尾逗号
# 匹配高德地图坐标格式 - 中国地区的坐标范围,放宽小数位数要求
pattern = r'([7-9]?[0-9]\.[0-9]{1,}|1[0-3][0-9]\.[0-9]{1,})[,,]\s*([1-8]?[0-9]\.[0-9]{1,}|90\.[0-9]{1,})'
matches = re.finditer(pattern, clean_text)
valid_coordinates = []
for match in matches:
lng = match.group(1).strip()
lat = match.group(2).strip()
# 转换为浮点数进行范围验证
try:
lng_float = float(lng)
lat_float = float(lat)
# 检查坐标是否在中国地区合理范围内
if 70 <= lng_float <= 140 and 0 <= lat_float <= 60:
# 计算坐标的精度(小数位数)
lng_precision = len(lng.split('.')[1]) if '.' in lng else 0
lat_precision = len(lat.split('.')[1]) if '.' in lat else 0
# 接受至少有1位小数的坐标,不再严格要求4位小数
valid_coordinates.append((lng_float, lat_float, lng, lat))
except ValueError:
continue
if valid_coordinates:
# 如果找到多个有效坐标,选择小数位数最多的
valid_coordinates.sort(
key=lambda x: len(x[2].split('.')[1]) + len(x[3].split('.')[1]) if '.' in x[2] and '.' in x[3] else 0,
reverse=True)
_, _, lng, lat = valid_coordinates[0]
return {
'lng': lng,
'lat': lat
}
else:
return None
except Exception as e:
return None
def export_cookies_from_browser(driver, output_file='amap_cookies.json'):
"""
导出浏览器中的Cookie到文件
Args:
driver: WebDriver实例
output_file: Cookie导出文件路径
Returns:
bool: 导出是否成功
"""
# 检查输入参数
if not driver:
print("❌ WebDriver实例无效")
return False
if not output_file:
print("❌ Cookie输出文件路径无效")
return False
try:
print(f"正在导出Cookie到文件: {output_file}")
# 确保目录存在
output_dir = os.path.dirname(output_file)
if output_dir and not os.path.exists(output_dir):
try:
os.makedirs(output_dir, exist_ok=True)
except Exception as e:
print(f"创建输出目录失败: {str(e)}")
return False
# 获取Cookie
cookies = driver.get_cookies()
if not cookies:
print("⚠️ 未获取到任何Cookie")
return False
# 保存Cookie到文件
with open(output_file, 'w', encoding='utf-8') as f:
json.dump(cookies, f, ensure_ascii=False, indent=2)
print(f"成功导出 {len(cookies)} 个Cookie")
print(f"Cookie已成功导出到: {os.path.abspath(output_file)}")
return True
except Exception as e:
print(f"导出Cookie失败: {str(e)}")
return False
def load_cookies_to_browser(driver, cookie_file='amap_cookies.json'):
"""
从文件加载Cookie到浏览器
Args:
driver: WebDriver实例
cookie_file: Cookie文件路径
Returns:
bool: 加载是否成功
"""
try:
# 检查输入参数
if not driver:
print("❌ WebDriver实例无效")
return False
if not cookie_file:
print("❌ Cookie文件路径无效")
return False
if not os.path.exists(cookie_file):
return False
# 先访问高德域名,确保Cookie可以被正确设置
import ssl
ssl._create_default_https_context = ssl._create_unverified_context
driver.get('https://www.amap.com')
time.sleep(1) # 等待页面加载
# 加载Cookie
with open(cookie_file, 'r', encoding='utf-8') as f:
cookies = json.load(f)
# 添加每个Cookie
success_count = 0
for cookie in cookies:
# 移除可能导致问题的属性
if 'expiry' in cookie:
cookie['expiry'] = int(cookie['expiry'])
if 'sameSite' in cookie and cookie['sameSite'] not in ['Strict', 'Lax', 'None']:
del cookie['sameSite']
try:
driver.add_cookie(cookie)
success_count += 1
except Exception as e:
#print(f"添加Cookie失败 (可能是安全策略限制): {cookie.get('name')} - {str(e)}")
pass
return True
except Exception as e:
print(f"加载Cookie失败: {str(e)}")
return False
def get_location_coordinates(location_name, headless=False, cookie_file='amap_cookies.json', driver=None,
reuse_driver=False):
"""
获取指定名称地点的坐标信息
Args:
location_name (str): 要搜索的地点名称
headless (bool): 是否使用无头模式,默认False(有界面)
cookie_file (str): Cookie文件路径
driver: 可选的WebDriver实例(用于复用)
reuse_driver: 是否复用WebDriver实例
Returns:
dict or None: 包含名称、经度和纬度的字典,失败时返回None
"""
# 检查输入参数
if not location_name or not location_name.strip():
print("❌ 地点名称无效")
return None
if not cookie_file or not os.path.exists(cookie_file):
print(f"❌ Cookie文件不存在或无效: {cookie_file}")
return None
# 浏览器管理标志
need_quit_driver = False
try:
# 如果没有提供driver,创建新的
if not driver:
# 设置Chrome浏览器参数
chrome_options = Options()
if headless:
chrome_options.add_argument('--headless')
# 添加必要的配置参数
# 我有GPU,可以放开
# chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--window-size=1920,1080')
chrome_options.add_argument('--lang=zh-CN')
# 禁用自动化控制特征
chrome_options.add_experimental_option('useAutomationExtension', False)
chrome_options.add_argument("--disable-blink-features=AutomationControlled") # 防止自动化检测,关闭浏览器控制显示
chrome_options.add_argument('-ignore-certificate-errors') # 忽略证书错误
chrome_options.add_argument('-ignore-ssl-errors') # 忽略相关错误
chrome_options.add_experimental_option('excludeSwitches', ['enable-automation']) # 防止自动化检测
chrome_options.add_experimental_option('excludeSwitches', ['enable-logging']) # 防止自动化日志输出检测
# 添加随机User-Agent
user_agent = f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{random.randint(80, 99)}.0.{random.randint(4000, 5000)}.{random.randint(100, 200)} Safari/537.36'
chrome_options.add_argument(f'user-agent={user_agent}')
# 尝试初始化WebDriver
service = Service()
try:
from webdriver_manager.chrome import ChromeDriverManager
driver_path = ChromeDriverManager().install()
service = Service(driver_path)
except:
pass
# 初始化WebDriver
driver = webdriver.Chrome(service=service, options=chrome_options)
need_quit_driver = True
# 最大化窗口(如果不是无头模式)
if not headless:
driver.maximize_window()
# 禁用自动化特征
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
})
# 使用Cookie方式认证
cookies_loaded = load_cookies_to_browser(driver, cookie_file)
if not cookies_loaded:
print("Cookie认证失败,无法继续获取高精度坐标")
return None
# 访问高德地图坐标拾取工具
url = 'https://lbs.amap.com/tools/picker'
driver.get(url)
time.sleep(2) # 等待页面完全加载
# 定位搜索输入框并输入查询内容
try:
# 使用ID定位搜索框
search_input = WebDriverWait(driver, TIMEOUT_MEDIUM).until(
EC.presence_of_element_located((By.ID, 'txtSearch'))
)
except Exception as e:
print(f"定位搜索输入框失败: {e}")
return None
# 输入搜索内容
search_input.clear()
search_input.send_keys(location_name)
# 定位搜索按钮并点击
try:
# 尝试使用class选择器定位搜索按钮
search_button = WebDriverWait(driver, TIMEOUT_SHORT).until(
EC.presence_of_element_located((By.CLASS_NAME, 'btn-search'))
)
search_button.click()
except Exception as e:
print(f"定位搜索按钮失败: {e}")
# 尝试按回车键搜索
try:
search_input.send_keys(Keys.ENTER)
except Exception as e2:
print(f"回车键搜索也失败: {e2}")
return None
# 等待搜索结果加载完成并显示坐标
time.sleep(3) # 等待时间,确保页面完全加载并显示搜索结果和坐标
# 获取坐标信息 - 只使用成功的方式(从txtCoordinate元素的value属性获取)
coordinate_text = None
# 查找ID为txtCoordinate的元素(这是高德地图坐标拾取器的核心坐标显示元素)
try:
coordinate_element = WebDriverWait(driver, TIMEOUT_MEDIUM).until(
EC.presence_of_element_located((By.ID, 'txtCoordinate'))
)
# 获取value属性(这是input元素存储值的标准方式)
coordinate_value = coordinate_element.get_attribute('value')
if coordinate_value and coordinate_value.strip():
coordinate_text = coordinate_value.strip()
else:
print("警告: txtCoordinate元素的value属性为空")
# 作为备选,尝试获取text内容
coordinate_text_content = coordinate_element.text.strip()
if coordinate_text_content:
coordinate_text = coordinate_text_content
print(f"从text内容获取到坐标文本: '{coordinate_text}'")
except Exception as e:
print(f"查找或获取坐标元素失败: {e}")
return None
# 如果没有获取到坐标,返回失败
if not coordinate_text or coordinate_text.strip() == '':
print("未能获取到有效的坐标文本")
return None
# 解析坐标信息
print(f"解析坐标文本: '{coordinate_text}'")
coordinate_info = parse_coordinates_info(coordinate_text)
if coordinate_info:
# 进一步验证坐标的合理性(中国地区范围)
try:
lng = float(coordinate_info['lng'])
lat = float(coordinate_info['lat'])
# 中国地区的合理坐标范围
if 70 <= lng <= 140 and 0 <= lat <= 60:
print(f"成功获取坐标: {coordinate_info['lng']},{coordinate_info['lat']}")
return coordinate_info
else:
print(f"坐标超出中国地区合理范围 - 经度: {lng}, 纬度: {lat}")
return None
except (ValueError, TypeError):
print(f"坐标数值验证失败: {coordinate_info}")
return None
else:
print("坐标解析失败")
return None
except Exception as e:
print(f"获取坐标时发生错误: {e}")
return None
finally:
# 关闭浏览器(如果不是复用的)
if not reuse_driver and need_quit_driver and driver:
driver.close()
driver.quit()
def get_multiple_locations_coordinates(location_names, headless=False, cookie_file='amap_cookies.json'):
"""
批量获取多个地点的坐标信息
Args:
location_names (list): 地点名称列表
headless (bool): 是否使用无头模式,默认False(有界面)
cookie_file (str): Cookie文件路径
Returns:
dict: 包含所有地点坐标信息的字典,键为地点名称,值为坐标信息字典或None(失败时)
"""
# 检查输入参数
if not location_names:
print("❌ 地点列表为空,无需获取坐标")
return {}
print(f"开始批量获取坐标信息 - 地点数量: {len(location_names)}, 无头模式: {headless}")
# 存储所有地点的坐标信息
all_coordinates = {}
success_count = 0
failure_count = 0
skipped_count = 0
invalid_coord_count = 0
# 创建并复用浏览器实例
created_driver = None
# 记录是否已经退出,避免重复退出
driver_closed = False
try:
# 初始化Chrome浏览器
chrome_options = Options()
if headless:
chrome_options.add_argument('--headless')
# 添加必要的配置参数
# chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--window-size=1920,1080')
chrome_options.add_argument('--lang=zh-CN')
chrome_options.add_argument("--disable-blink-features=AutomationControlled") # 防止自动化检测,关闭浏览器控制显示
chrome_options.add_argument('-ignore-certificate-errors') # 忽略证书错误
chrome_options.add_argument('-ignore-ssl-errors') # 忽略相关错误
chrome_options.add_experimental_option('excludeSwitches', ['enable-automation']) # 防止自动化检测
chrome_options.add_experimental_option('excludeSwitches', ['enable-logging']) # 防止自动化日志输出检测
chrome_options.add_experimental_option('useAutomationExtension', False)
# 添加随机User-Agent
user_agent = f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{random.randint(80, 99)}.0.{random.randint(4000, 5000)}.{random.randint(100, 200)} Safari/537.36'
chrome_options.add_argument(f'user-agent={user_agent}')
# 尝试使用webdriver_manager
try:
from webdriver_manager.chrome import ChromeDriverManager
driver_path = ChromeDriverManager().install()
service = Service(driver_path)
print(f"成功使用webdriver_manager安装驱动: {driver_path}")
except:
print("使用系统默认的Chrome驱动")
service = Service()
# 初始化WebDriver
created_driver = webdriver.Chrome(service=service, options=chrome_options)
# 将创建的driver实例添加到全局跟踪集合中
if 'driver_instances' in globals():
driver_instances.add(created_driver)
# 最大化窗口(如果不是无头模式)
if not headless:
created_driver.maximize_window()
# 禁用自动化特征
created_driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
})
# 使用Cookie方式认证
print("尝试使用Cookie文件进行认证")
cookies_loaded = load_cookies_to_browser(created_driver, cookie_file)
if not cookies_loaded:
print("Cookie认证失败,无法继续获取高精度坐标")
return all_coordinates
# 访问高德地图坐标拾取工具
url = 'https://lbs.amap.com/tools/picker'
created_driver.get(url)
time.sleep(2) # 增加等待时间以确保页面完全加载
# 循环获取每个地点的坐标
for i, location_name in enumerate(location_names, 1):
print(f"处理地点 {i}/{len(location_names)}: '{location_name}'")
# 对每个地点查询
try:
# 调用get_location_coordinates获取坐标
result = get_location_coordinates(location_name, headless=headless, cookie_file=cookie_file,
driver=created_driver, reuse_driver=True)
except Exception as e:
print(f"❌ 地点: {location_name} - 查询出错: {str(e)},跳过该地点")
result = None
skipped_count += 1
# 存储结果
all_coordinates[location_name] = result
# 坐标获取成功,进行有效性验证
if result:
# 验证坐标的地理合理性(中国地区范围)
try:
lng = float(result['lng'])
lat = float(result['lat'])
# 中国地区的合理坐标范围
if 70 <= lng <= 140 and 0 <= lat <= 60:
# 检查坐标精度(至少4位小数)
lng_precision = len(result['lng'].split('.')[1]) if '.' in result['lng'] else 0
lat_precision = len(result['lat'].split('.')[1]) if '.' in result['lat'] else 0
if lng_precision >= 4 and lat_precision >= 4:
print(f"✅ 地点: {location_name} - 坐标获取成功: {result['lng']},{result['lat']}")
success_count += 1
else:
print(
f"⚠️ 地点: {location_name} - 坐标精度不足: {result['lng']},{result['lat']} (经度{lng_precision}位, 纬度{lat_precision}位小数)")
invalid_coord_count += 1
else:
print(f"⚠️ 地点: {location_name} - 坐标超出中国地区合理范围: {result['lng']},{result['lat']}")
invalid_coord_count += 1
except (ValueError, TypeError):
print(f"⚠️ 地点: {location_name} - 坐标数值无效: {result}")
invalid_coord_count += 1
else:
print(f"❌ 地点: {location_name} - 未能获取有效的坐标信息")
failure_count += 1
# 如果不是最后一个地点,添加短暂延迟以避免过快请求
if i < len(location_names):
delay_time = random.uniform(0.5, 2) # 减少延迟时间到0.5-2秒
print(f"添加延迟: {delay_time:.2f}秒")
time.sleep(delay_time)
except Exception as e:
print(f"❌ 批量坐标获取过程中发生错误: {str(e)}")
finally:
created_driver.close()
# 打印详细统计信息
print(f"\n{'=' * 60}")
print("批量坐标获取完成")
print(f"{'=' * 60}")
print(f"总查询地点数: {len(location_names)}")
print(f"成功获取: {success_count}")
print(f"获取失败: {failure_count}")
print(f"超时跳过: {skipped_count}")
print(f"无效坐标: {invalid_coord_count}")
print(f"获取到坐标数据的地点数: {len([c for c in all_coordinates.values() if c is not None])}")
# 计算实际成功率(只考虑成功获取的有效坐标)
actual_processed = success_count + failure_count + skipped_count + invalid_coord_count
if actual_processed > 0:
actual_success_rate = (success_count / actual_processed) * 100
print(f"实际成功率: {actual_success_rate:.1f}%")
else:
print("实际成功率: 0%")
print(f"{'=' * 60}")
# 返回结果字典
return all_coordinates
def create_cookie_export_tool():
"""
创建一个Cookie导出工具,帮助用户手动登录后导出Cookie
"""
print("\n===== Cookie导出工具 =====")
print("此工具将打开一个Chrome浏览器窗口,让您手动登录高德地图账号,")
print("登录成功后,按Enter键导出Cookie到文件。")
print("==========================")
# 设置Chrome浏览器参数
chrome_options = Options()
chrome_options.add_argument('--disable-gpu')
chrome_options.add_argument('--window-size=1920,1080')
chrome_options.add_experimental_option("excludeSwitches", ["enable-automation"])
chrome_options.add_experimental_option('useAutomationExtension', False)
# 初始化WebDriver
driver = None
try:
# 尝试使用webdriver_manager
try:
from webdriver_manager.chrome import ChromeDriverManager
driver_path = ChromeDriverManager().install()
service = Service(driver_path)
except:
service = Service()
driver = webdriver.Chrome(service=service, options=chrome_options)
driver.maximize_window()
# 禁用自动化特征
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
})
# 访问高德地图坐标拾取工具
url = 'https://lbs.amap.com/tools/picker'
print(f"\n正在打开高德地图坐标拾取工具: {url}")
import ssl
ssl._create_default_https_context = ssl._create_unverified_context
driver.get(url)
print("\n请在浏览器中手动登录高德地图账号。")
print("提示:建议使用微信扫码登录以避免验证码问题")
print("登录成功后,请按Enter键继续...")
input()
# 导出Cookie - 使用基于当前脚本所在目录的绝对路径
script_dir = os.path.dirname(os.path.abspath(__file__))
cookie_file = os.path.join(script_dir, '../Test/amap_cookies.json')
export_success = export_cookies_from_browser(driver, cookie_file)
if export_success:
print(f"\nCookie导出成功!文件保存为: {os.path.abspath(cookie_file)}")
print("您现在可以使用这个Cookie文件获取高精度坐标了。")
print("\n重要提示:")
print("1. 请妥善保管您的Cookie文件,不要分享给他人")
print("2. Cookie文件包含您的登录状态信息,请确保安全存储")
print("3. 当Cookie过期后,您需要重新运行此工具生成新的Cookie文件")
except Exception as e:
print(f"\n发生错误: {str(e)}")
finally:
if driver:
print("\n正在关闭浏览器...")
driver.quit()
def main():
"""
主函数,用于测试和运行高德地图坐标获取工具
"""
print("=== 高德地图高精度坐标获取工具 ====")
# 初始化参数
# 使用基于当前脚本所在目录的相对路径,确保在不同环境下都能正确访问
script_dir = os.path.dirname(os.path.abspath(__file__))
cookie_file = os.path.join(script_dir, '../Test/amap_cookies.json')
# 询问是否清空所有数据的经纬度
clear_coords = input("是否清空所有数据的经纬度,全新开始更新坐标?(y/n,默认n): ").strip().lower() == 'y'
if clear_coords:
print("正在清空所有学校的经纬度数据...")
try:
execute_update("UPDATE t_school SET longitude=0, latitude=0")
print("✅ 经纬度数据已清空")
except Exception as e:
print(f"❌ 清空经纬度数据失败: {str(e)}")
sys.exit(1)
# 从数据库查询需要更新坐标的学校(经纬度为0的学校)
print("正在从数据库查询需要更新坐标的学校...")
try:
results = find("SELECT org_name FROM t_school WHERE longitude=0 AND latitude=0 order by region_level1,region_level2,region_level3,school_type")
locations = [result['org_name'] for result in results]
print(f"✅ 找到 {len(locations)} 所需要更新坐标的学校")
if not locations:
print("所有学校都已有坐标数据,程序退出。")
sys.exit(0)
# 显示前10个学校作为预览
print("\n待处理学校列表预览:")
for i, loc in enumerate(locations[:10], 1):
print(f" {i}. {loc}")
if len(locations) > 10:
print(f" ... 等{len(locations) - 10}个学校")
except Exception as e:
print(f"❌ 查询学校列表失败: {str(e)}")
sys.exit(1)
# 检查Cookie文件
if not os.path.exists(cookie_file):
print(f"\nCookie文件 '{cookie_file}' 不存在。")
if input("是否现在运行Cookie导出工具? (y/n): ").strip().lower() == 'y':
create_cookie_export_tool()
else:
print("\n程序退出。没有有效的Cookie文件,无法获取高精度坐标。")
sys.exit(1)
print(f"\n运行参数: 无头模式{'启用' if HEADLESS else '禁用'}")
# 逐个获取坐标并立即更新数据库
print("\n开始逐个获取坐标并更新数据库...")
success_updates = 0
total_locations = len(locations)
# 创建浏览器实例(复用)
chrome_options = Options()
if HEADLESS:
chrome_options.add_argument('--headless')
# 添加必要的配置参数
chrome_options.add_argument('--no-sandbox')
chrome_options.add_argument('--disable-dev-shm-usage')
chrome_options.add_argument('--window-size=1920,1080')
chrome_options.add_argument('--lang=zh-CN')
chrome_options.add_argument("--disable-blink-features=AutomationControlled")
chrome_options.add_argument('-ignore-certificate-errors')
chrome_options.add_argument('-ignore-ssl-errors')
chrome_options.add_experimental_option('excludeSwitches', ['enable-automation'])
chrome_options.add_experimental_option('excludeSwitches', ['enable-logging'])
chrome_options.add_experimental_option('useAutomationExtension', False)
# 随机User-Agent
user_agent = f'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{random.randint(80, 99)}.0.{random.randint(4000, 5000)}.{random.randint(100, 200)} Safari/537.36'
chrome_options.add_argument(f'user-agent={user_agent}')
# 初始化WebDriver
try:
from webdriver_manager.chrome import ChromeDriverManager
driver_path = ChromeDriverManager().install()
service = Service(driver_path)
print(f"成功使用webdriver_manager安装驱动: {driver_path}")
except:
print("使用系统默认的Chrome驱动")
service = Service()
driver = webdriver.Chrome(service=service, options=chrome_options)
# 添加到全局跟踪集合
if 'driver_instances' in globals():
driver_instances.add(driver)
# 最大化窗口(如果不是无头模式)
if not HEADLESS:
driver.maximize_window()
# 禁用自动化特征
driver.execute_cdp_cmd("Page.addScriptToEvaluateOnNewDocument", {
"source": "Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
})
# 使用Cookie方式认证
cookies_loaded = load_cookies_to_browser(driver, cookie_file)
if not cookies_loaded:
print("Cookie认证失败,无法继续获取高精度坐标")
driver.quit()
return
# 访问高德地图坐标拾取工具
url = 'https://lbs.amap.com/tools/picker'
driver.get(url)
time.sleep(2) # 等待页面完全加载
update_sql = "UPDATE t_school SET longitude = :lng, latitude = :lat WHERE org_name = :org_name"
try:
# 逐个处理每个地点
for i, location_name in enumerate(locations, 1):
print(f"处理地点 {i}/{total_locations}: '{location_name}'")
# 获取单个地点的坐标
try:
coords = get_location_coordinates(
location_name,
headless=HEADLESS,
cookie_file=cookie_file,
driver=driver,
reuse_driver=True
)
# 如果成功获取到坐标,立即更新数据库
if coords:
try:
execute_update(update_sql, {
'lng': coords['lng'],
'lat': coords['lat'],
'org_name': location_name
})
success_updates += 1
print(f"✅ 地点: {location_name} - 坐标获取成功并更新数据库: {coords['lng']},{coords['lat']}")
except Exception as e:
print(f" ❌ 更新地点'{location_name}'失败: {str(e)}")
else:
print(f"❌ 地点: {location_name} - 未能获取有效的坐标信息")
# 添加短暂延迟以避免过快请求
if i < total_locations:
delay_time = random.uniform(0.5, 2)
print(f"添加延迟: {delay_time:.2f}秒")
time.sleep(delay_time)
except Exception as e:
print(f"❌ 地点: {location_name} - 查询出错: {str(e)}")
# 添加延迟后继续处理下一个地点
if i < total_locations:
time.sleep(random.uniform(0.5, 2))
finally:
# 关闭浏览器
driver.close()
driver.quit()
print(f"\n✅ 数据库更新完成,成功{success_updates}/{total_locations}条记录")
print("\n✅ 程序执行完成!")
print("\n提示:如需重新批量更新所有学校坐标,下次运行程序时选择清空所有数据的经纬度即可。")
if __name__ == "__main__":
# 全局变量,用于跟踪所有创建的WebDriver实例
driver_instances = weakref.WeakSet()
try:
print("=== 高德地图高精度坐标获取工具 ===")
print("正在初始化...")
# 直接运行主程序
main()
except KeyboardInterrupt:
print("\n⚠️ 接收到键盘中断,退出程序")
except Exception as main_error:
print(f"\n❌ 主程序运行出错: {main_error}")
print("\n程序已退出")

View File

@@ -0,0 +1,810 @@
import json
import random
import asyncio
import math
from typing import List, Tuple, Dict, Any
from DbKit.Db import Db
def is_point_in_polygon(point: Tuple[float, float], polygon: List[Tuple[float, float]]) -> bool:
"""
射线法判断点是否在多边形内
:param point: (经度, 纬度)
:param polygon: 多边形顶点坐标列表
:return: True表示在多边形内
"""
x, y = point
n = len(polygon)
inside = False
for i in range(n):
x1, y1 = polygon[i]
x2, y2 = polygon[(i + 1) % n]
# 检查点是否在边的y范围内
if ((y1 > y) != (y2 > y)):
# 计算交点x坐标
x_intersect = (x2 - x1) * (y - y1) / (y2 - y1) + x1
if x_intersect > x:
inside = not inside
return inside
def load_geojson(json_path: str) -> List[List[Tuple[float, float]]]:
"""
从GeoJSON文件加载多边形数据(支持Polygon和MultiPolygon)
返回: 多边形列表,每个多边形是(经度, 纬度)元组的列表
"""
with open(json_path, 'r', encoding='utf-8') as f:
geo_data = json.load(f)
polygons = []
geometry_type = geo_data['features'][0]['geometry']['type']
coordinates = geo_data['features'][0]['geometry']['coordinates']
if geometry_type == 'Polygon':
polygons.append(coordinates[0])
elif geometry_type == 'MultiPolygon':
for polygon_coords in coordinates:
polygons.append(polygon_coords[0])
return polygons
# 计算点集的分布统计信息
def calculate_distribution_statistics(points):
"""
计算点集的分布统计信息
:param points: 点集 [(lon, lat), ...]
:return: 包含min_lon, max_lon, min_lat, max_lat, center_lon, center_lat, std_lon, std_lat的字典
"""
if not points:
return None
lons = [p[0] for p in points]
lats = [p[1] for p in points]
min_lon, max_lon = min(lons), max(lons)
min_lat, max_lat = min(lats), max(lats)
# 计算中心点
center_lon = (min_lon + max_lon) / 2
center_lat = (min_lat + max_lat) / 2
# 计算标准差(衡量分布的分散程度)
mean_lon = sum(lons) / len(lons)
mean_lat = sum(lats) / len(lats)
std_lon = math.sqrt(sum((lon - mean_lon) ** 2 for lon in lons) / len(lons))
std_lat = math.sqrt(sum((lat - mean_lat) ** 2 for lat in lats) / len(lats))
return {
"min_lon": min_lon,
"max_lon": max_lon,
"min_lat": min_lat,
"max_lat": max_lat,
"center_lon": center_lon,
"center_lat": center_lat,
"std_lon": std_lon,
"std_lat": std_lat
}
# 线性映射函数
def linear_map(value, from_min, from_max, to_min, to_max):
"""
将值从一个范围线性映射到另一个范围
:param value: 输入值
:param from_min: 原始范围最小值
:param from_max: 原始范围最大值
:param to_min: 目标范围最小值
:param to_max: 目标范围最大值
:return: 映射后的值
"""
# 处理除零情况
if from_max == from_min:
return (to_min + to_max) / 2
# 计算映射值
return (value - from_min) * (to_max - to_min) / (from_max - from_min) + to_min
# 计算点到线段的最近点
def closest_point_on_segment(point, line_start, line_end):
"""
计算点到线段的最近点
:param point: 点 [lon, lat]
:param line_start: 线段起点 [lon, lat]
:param line_end: 线段终点 [lon, lat]
:return: 线段上的最近点 [lon, lat]
"""
# 将经纬度转换为向量
a = [point[0] - line_start[0], point[1] - line_start[1]]
b = [line_end[0] - line_start[0], line_end[1] - line_start[1]]
# 计算投影参数t
t = max(0, min(1, (a[0] * b[0] + a[1] * b[1]) / (b[0] ** 2 + b[1] ** 2) if (b[0] ** 2 + b[1] ** 2) > 0 else 0))
# 计算投影点
return [line_start[0] + t * b[0], line_start[1] + t * b[1]]
# 计算两点之间的距离
def calculate_distance(point1, point2):
"""
计算两点之间的欧几里得距离
:param point1: 第一个点 [lon, lat]
:param point2: 第二个点 [lon, lat]
:return: 两点之间的距离
"""
return math.sqrt((point1[0] - point2[0]) ** 2 + (point1[1] - point2[1]) ** 2)
# 调整点到多边形边界
def adjust_to_polygon_boundary(point, polygons):
"""
调整点到最近的多边形边界
:param point: 需要调整的点 [lon, lat]
:param polygons: 多边形列表
:return: 调整后的点 [lon, lat]
"""
min_distance = float('inf')
closest_point = point
for polygon in polygons:
for i in range(len(polygon)):
p1 = polygon[i]
p2 = polygon[(i + 1) % len(polygon)]
# 计算点到线段的最近点
proj_point = closest_point_on_segment(point, p1, p2)
distance = calculate_distance(point, proj_point)
if distance < min_distance:
min_distance = distance
closest_point = proj_point
return closest_point
# 将原始坐标映射到目标区域,保持相对位置关系
def map_coordinates(original_points, polygons):
"""
将原始坐标映射到目标区域,保持相对位置关系
:param original_points: 原始坐标列表 [(original_lon, original_lat), ...]
:param polygons: 目标区域的多边形列表
:return: 映射后的坐标列表 [(mapped_lon, mapped_lat), ...]
"""
if not original_points or not polygons:
return []
# 计算原始点集的分布统计
original_stats = calculate_distribution_statistics(original_points)
if not original_stats:
return []
# 获取目标区域的边界信息
all_target_points = []
for polygon in polygons:
all_target_points.extend(polygon)
if not all_target_points:
return []
target_lons = [p[0] for p in all_target_points]
target_lats = [p[1] for p in all_target_points]
target_min_lon, target_max_lon = min(target_lons), max(target_lons)
target_min_lat, target_max_lat = min(target_lats), max(target_lats)
# 收缩目标边界框,使生成的坐标更靠近县区中心
shrink_factor = 0.25 # 收缩25%,增大收缩比例使坐标更靠近中心
delta_lon = (target_max_lon - target_min_lon) * shrink_factor
delta_lat = (target_max_lat - target_min_lat) * shrink_factor
target_min_lon += delta_lon
target_max_lon -= delta_lon
target_min_lat += delta_lat
target_max_lat -= delta_lat
# 计算原始点集的边界
original_min_lon = original_stats["min_lon"]
original_max_lon = original_stats["max_lon"]
original_min_lat = original_stats["min_lat"]
original_max_lat = original_stats["max_lat"]
mapped_points = []
for original_lon, original_lat in original_points:
# 线性映射经度和纬度
mapped_lon = linear_map(original_lon, original_min_lon, original_max_lon, target_min_lon, target_max_lon)
mapped_lat = linear_map(original_lat, original_min_lat, original_max_lat, target_min_lat, target_max_lat)
# 检查映射后的点是否在目标区域内
if any(is_point_in_polygon((mapped_lon, mapped_lat), polygon) for polygon in polygons):
mapped_points.append((mapped_lon, mapped_lat))
else:
# 如果映射后的点不在目标区域内,调整到最近的边界
adjusted_lon, adjusted_lat = adjust_to_polygon_boundary([mapped_lon, mapped_lat], polygons)
mapped_points.append((adjusted_lon, adjusted_lat))
return mapped_points
# 传统的均匀分布点生成算法(作为回退方案)
def generate_uniform_points_fallback(polygons, n_points):
"""
在指定的多边形区域内生成均匀分布的点(回退方案)
:param polygons: 多边形列表,每个多边形是坐标点的列表
:param n_points: 需要生成的点数
:return: 生成的点列表 [(lon, lat), ...]
"""
if not polygons:
return []
# 计算整体边界框
all_lons = [p[0] for polygon in polygons for p in polygon]
all_lats = [p[1] for polygon in polygons for p in polygon]
min_lon, max_lon = min(all_lons), max(all_lons)
min_lat, max_lat = min(all_lats), max(all_lats)
# 收缩边界框,使生成的坐标更靠近县区中心
lon_margin = (max_lon - min_lon) * 0.25 # 收缩25%,增大收缩比例使坐标更靠近中心
lat_margin = (max_lat - min_lat) * 0.25
min_lon = min_lon + lon_margin
max_lon = max_lon - lon_margin
min_lat = min_lat + lat_margin
max_lat = max_lat - lat_margin
# 计算每个多边形的面积,用于按比例分配点数
def calculate_polygon_area(polygon):
# 使用Shoelace公式计算多边形面积
area = 0.0
n = len(polygon)
for i in range(n):
j = (i + 1) % n
area += polygon[i][0] * polygon[j][1]
area -= polygon[j][0] * polygon[i][1]
return abs(area) / 2.0
# 计算每个多边形的面积
polygon_areas = [calculate_polygon_area(polygon) for polygon in polygons]
total_area = sum(polygon_areas)
# 根据面积比例分配每个多边形需要生成的点数
if total_area > 0:
points_per_polygon = [max(1, int(area / total_area * n_points)) for area in polygon_areas]
# 调整点数,确保总和等于n_points
points_per_polygon[-1] += n_points - sum(points_per_polygon)
else:
points_per_polygon = [n_points // len(polygons)] * len(polygons)
points_per_polygon[-1] += n_points - sum(points_per_polygon)
points = []
# 为每个多边形单独生成点
for polygon_index, (polygon, points_needed) in enumerate(zip(polygons, points_per_polygon)):
if points_needed <= 0:
continue
# 计算当前多边形的边界框
polygon_lons = [p[0] for p in polygon]
polygon_lats = [p[1] for p in polygon]
poly_min_lon, poly_max_lon = min(polygon_lons), max(polygon_lons)
poly_min_lat, poly_max_lat = min(polygon_lats), max(polygon_lats)
# 收缩当前多边形的边界框,使其更靠近中心
poly_lon_margin = (poly_max_lon - poly_min_lon) * 0.25 # 收缩25%,与全局收缩比例保持一致
poly_lat_margin = (poly_max_lat - poly_min_lat) * 0.25
poly_min_lon += poly_lon_margin
poly_max_lon -= poly_lon_margin
poly_min_lat += poly_lat_margin
poly_max_lat -= poly_lat_margin
# 为当前多边形生成足够的网格
cells_needed = max(points_needed * 2, 10) # 网格数量是点数的2倍,确保分布均匀
cols = math.ceil(math.sqrt(cells_needed))
rows = math.ceil(cells_needed / cols)
cell_width = (poly_max_lon - poly_min_lon) / cols
cell_height = (poly_max_lat - poly_min_lat) / rows
# 记录哪些网格已经生成了点
filled_cells = []
max_attempts_per_cell = 50
# 按网格生成点
for attempt in range(rows * cols * 2): # 增加尝试次数
if len(points) >= n_points:
break
# 随机选择一个网格
i = random.randint(0, rows - 1)
j = random.randint(0, cols - 1)
cell_key = (i, j)
# 如果这个网格已经生成了足够的点,跳过
if filled_cells.count(cell_key) >= math.ceil(points_needed / (rows * cols)):
continue
# 当前网格范围
cell_min_lon = poly_min_lon + j * cell_width
cell_max_lon = poly_min_lon + (j + 1) * cell_width
cell_min_lat = poly_min_lat + i * cell_height
cell_max_lat = poly_min_lat + (i + 1) * cell_height
# 在网格内尝试生成有效点
for _ in range(max_attempts_per_cell):
lon = random.uniform(cell_min_lon, cell_max_lon)
lat = random.uniform(cell_min_lat, cell_max_lat)
# 检查点是否在当前多边形内
if is_point_in_polygon((lon, lat), polygon):
points.append((lon, lat))
filled_cells.append(cell_key)
break
# 如果当前多边形的点数不足,使用均匀分布补充
while len(points) < sum(points_per_polygon[:polygon_index+1]):
lon = random.uniform(poly_min_lon, poly_max_lon)
lat = random.uniform(poly_min_lat, poly_max_lat)
if is_point_in_polygon((lon, lat), polygon):
points.append((lon, lat))
# 如果总点数还是不足,使用全局均匀分布补充
while len(points) < n_points:
# 随机选择一个多边形
polygon = random.choice(polygons)
# 计算该多边形的边界框
polygon_lons = [p[0] for p in polygon]
polygon_lats = [p[1] for p in polygon]
poly_min_lon, poly_max_lon = min(polygon_lons), max(polygon_lons)
poly_min_lat, poly_max_lat = min(polygon_lats), max(polygon_lats)
# 在边界框内随机生成点
for _ in range(100): # 最多尝试100次
lon = random.uniform(poly_min_lon, poly_max_lon)
lat = random.uniform(poly_min_lat, poly_max_lat)
if is_point_in_polygon((lon, lat), polygon):
points.append((lon, lat))
break
return points[:n_points]
# 在指定的多边形区域内生成均匀分布的点
async def generate_uniform_points(polygons: List[List[Tuple[float, float]]], num_points: int, original_coords=None) -> List[Tuple[float, float]]:
"""
在指定的多边形区域内生成均匀分布的点
:param polygons: 多边形列表,每个多边形是坐标点的列表
:param num_points: 需要生成的点数
:param original_coords: 原始坐标列表,用于参考相对位置关系
:return: 生成的点列表 [(lon, lat), ...]
"""
if not polygons:
return []
# 如果提供了原始坐标,使用坐标映射算法
if original_coords and len(original_coords) > 1:
# 过滤掉None值的原始坐标
valid_original_coords = [(coord["original_longitude"], coord["original_latitude"])
for coord in original_coords
if coord["original_longitude"] is not None
and coord["original_latitude"] is not None]
if len(valid_original_coords) >= num_points:
# 如果原始坐标足够,直接使用映射算法
return map_coordinates(valid_original_coords[:num_points], polygons)
elif valid_original_coords: # 如果有部分原始坐标,先映射这些,再补充生成
mapped_points = map_coordinates(valid_original_coords, polygons)
# 计算还需要生成的点数
remaining_points = num_points - len(mapped_points)
if remaining_points > 0:
# 补充生成均匀分布的点
fallback_points = generate_uniform_points_fallback(polygons, remaining_points)
mapped_points.extend(fallback_points)
return mapped_points[:num_points]
# 如果没有原始坐标或原始坐标不足,使用传统的均匀分布点生成算法
return generate_uniform_points_fallback(polygons, num_points)
# 导入数据库操作类
db = Db()
# 一共有哪些县区
async def getAllArea():
sql = "select area_code,area_name from t_dm_area where area_level=3 order by area_code"
return await db.find(sql)
# 根据名称获取县区代码
async def getAreaCode(areaName):
sql = "select area_code from t_dm_area where area_level=3 and area_name=:area_name"
res = await db.findFirst(sql, {"area_name": areaName})
# 需要去掉最后的三个0
return str(res["area_code"])[:-3]
# 从t_school_xy表获取指定县区和学校类型的原始坐标数据
async def get_original_school_coordinates(region_level3: str, school_types: List[str]) -> List[Dict[str, Any]]:
"""
从t_school_xy表获取指定县区和学校类型的原始坐标数据
:param region_level3: 县区名称
:param school_types: 学校类型列表
:return: 包含学校信息和原始坐标的列表
"""
if not school_types:
return []
# 构建SQL查询,获取原始坐标数据
placeholders = ",".join([f":type{i}" for i in range(len(school_types))])
sql = f"""
SELECT org_name, school_type, longitude AS original_longitude, latitude AS original_latitude,
region_level1, region_level2, region_level3, region_level4, region_level5
FROM t_school_xy
WHERE region_level3 = :region_level3 AND school_type IN ({placeholders})
"""
params = {"region_level3": region_level3}
# 添加每个学校类型的参数
for i, st in enumerate(school_types):
params[f"type{i}"] = st
# 获取原始坐标数据
original_coords = await db.find(sql, params)
# 转换坐标数据类型,确保longitude和latitude是float类型
# 这可以避免与代码中其他float类型进行数学运算时出现类型不兼容错误
from decimal import Decimal
for coord in original_coords:
if isinstance(coord.get("original_longitude"), Decimal):
coord["original_longitude"] = float(coord["original_longitude"])
if isinstance(coord.get("original_latitude"), Decimal):
coord["original_latitude"] = float(coord["original_latitude"])
return original_coords
def get_school_types_by_education_level(education_level: str) -> List[str]:
"""
根据学段获取对应的学校类型列表
:param education_level: 学段名称,可选值:学前、小学、初中、高中
:return: 对应学校类型的列表,用于SQL查询中的in子句
"""
# 定义学段到学校类型的映射关系
school_type_mapping = {
"学前": ["附设幼儿班", "幼儿园"],
"小学": ["小学教学点", "小学", "附设小学班"],
"初中": ["初级中学", "专门学校", "附设普通初中班", "附设职业初中班", "九年一贯制学校"],
"高中": ["附设普通高中班", "高级中学", "十二年一贯制学校", "完全中学"]
}
# 返回对应的学校类型列表,如果输入无效则返回空列表
return school_type_mapping.get(education_level, [])
# 获取指定县区+指定学校类型的学校,同时包含原始坐标信息
async def getSchoolList(region_level3, school_type):
# region_level3 是指县区的名称
# 检查是否传入的是学段名称
if school_type in ["学前", "小学", "初中", "高中"]:
school_types = get_school_types_by_education_level(school_type)
if not school_types:
return []
# 使用in子句查询多个学校类型
placeholders = ",".join([f":type{i}" for i in range(len(school_types))])
# 1. 从t_school表获取需要更新的学校列表
sql_school = f"""
select region_level1,region_level2,region_level3,region_level4,region_level5,school_type,org_name
from t_school
where region_level3=:region_level3 and school_type in ({placeholders})
order by region_level1,region_level2,region_level3,region_level4,region_level5,school_type
"""
params = {"region_level3": region_level3}
# 添加每个学校类型的参数
for i, st in enumerate(school_types):
params[f"type{i}"] = st
schools = await db.find(sql_school, params)
if not schools:
return []
# 2. 从t_school_xy表获取原始坐标数据
original_coords = await get_original_school_coordinates(region_level3, school_types)
# 3. 创建原始坐标字典,方便查找
original_coords_dict = {}
for coord in original_coords:
original_coords_dict[coord["org_name"]] = {
"original_longitude": coord["original_longitude"],
"original_latitude": coord["original_latitude"]
}
# 4. 合并原始坐标信息到学校列表
for school in schools:
org_name = school["org_name"]
if org_name in original_coords_dict:
school["original_longitude"] = original_coords_dict[org_name]["original_longitude"]
school["original_latitude"] = original_coords_dict[org_name]["original_latitude"]
else:
# 如果没有原始坐标,设置为None
school["original_longitude"] = None
school["original_latitude"] = None
return schools
else:
# 原有的单个学校类型查询逻辑(简化版,不包含原始坐标合并)
sql = "select region_level1,region_level2,region_level3,region_level4,region_level5,school_type,org_name from t_school where region_level3=:region_level3 and school_type=:school_type order by region_level1,region_level2,region_level3,region_level4,region_level5,school_type"
schools = await db.find(sql, {"region_level3": region_level3, "school_type": school_type})
# 获取原始坐标并合并
original_coords = await get_original_school_coordinates(region_level3, [school_type])
original_coords_dict = {
coord["org_name"]: {
"original_longitude": coord["original_longitude"],
"original_latitude": coord["original_latitude"]
}
for coord in original_coords
}
for school in schools:
org_name = school["org_name"]
if org_name in original_coords_dict:
school["original_longitude"] = original_coords_dict[org_name]["original_longitude"]
school["original_latitude"] = original_coords_dict[org_name]["original_latitude"]
else:
school["original_longitude"] = None
school["original_latitude"] = None
return schools
# 获取指定县区下+指定学段的学校数量
async def getSchoolCount(region_level3, school_type):
# 检查是否传入的是学段名称
if school_type in ["学前", "小学", "初中", "高中"]:
school_types = get_school_types_by_education_level(school_type)
if not school_types:
return {"c": 0}
# 使用in子句查询多个学校类型
placeholders = ",".join([f":type{i}" for i in range(len(school_types))])
sql = f"select count(1) as c from t_school where region_level3=:region_level3 and school_type in ({placeholders})"
params = {"region_level3": region_level3}
# 添加每个学校类型的参数
for i, st in enumerate(school_types):
params[f"type{i}"] = st
return await db.findFirst(sql, params)
else:
# 原有的单个学校类型查询逻辑
sql = "select count(1) as c from t_school where region_level3=:region_level3 and school_type=:school_type"
return await db.findFirst(sql, {"region_level3": region_level3, "school_type": school_type})
# 更新学校经纬度
async def updateSchool(org_name, longitude, latitude):
sql = "update t_school set longitude=:longitude,latitude=:latitude where org_name=:org_name"
return await db.execute_update(sql, {"org_name": org_name, "longitude": longitude, "latitude": latitude})
# 重置所有学校坐标为0
async def reset_all_school_coordinates():
"""
将所有学校的经纬度坐标重置为0
"""
print("\n正在重置所有学校坐标...")
sql = "update t_school set longitude=0,latitude=0"
try:
result = await db.execute_update(sql, {})
print(f"✅ 成功重置 {result} 条学校记录的坐标")
return True
except Exception as e:
print(f"❌ 重置坐标失败: {str(e)}")
return False
async def get_schools_by_county_and_level(county_name, education_level):
"""
获取指定县区和学段的学校列表,包含当前坐标信息
只从t_school表获取数据,不依赖t_school_xy表
"""
# 获取对应的学校类型列表
school_types = get_school_types_by_education_level(education_level)
if not school_types:
return []
# 使用in子句查询多个学校类型
placeholders = ",".join([f":type{i}" for i in range(len(school_types))])
# 从t_school表获取学校数据和当前坐标
sql = f"""
select region_level1, region_level2, region_level3, region_level4, region_level5,
school_type, org_name, longitude, latitude
from t_school
where region_level3=:county_name and school_type in ({placeholders})
order by region_level1, region_level2, region_level3, region_level4, region_level5, school_type
"""
params = {"county_name": county_name}
# 添加每个学校类型的参数
for i, st in enumerate(school_types):
params[f"type{i}"] = st
schools = await db.find(sql, params)
# 转换坐标数据类型,确保longitude和latitude是float类型
from decimal import Decimal
for school in schools:
if isinstance(school.get("longitude"), Decimal):
school["longitude"] = float(school["longitude"])
if isinstance(school.get("latitude"), Decimal):
school["latitude"] = float(school["latitude"])
return schools
def optimize_school_distribution(schools, polygons):
"""
优化学校坐标分布
- 对于已经在多边形内部的坐标,保持不变
- 对于不在多边形内部的坐标,将其调整到多边形中
"""
if not schools:
return []
# 获取目标区域的边界信息
all_target_points = []
for polygon in polygons:
all_target_points.extend(polygon)
target_lons = [p[0] for p in all_target_points]
target_lats = [p[1] for p in all_target_points]
target_min_lon, target_max_lon = min(target_lons), max(target_lons)
target_min_lat, target_max_lat = min(target_lats), max(target_lats)
# 处理每个学校的坐标
optimized_coords = []
for school in schools:
lon, lat = school["longitude"], school["latitude"]
# 检查坐标是否已经在多边形内
if any(is_point_in_polygon((lon, lat), polygon) for polygon in polygons):
# 如果在多边形内,保持原坐标不变
optimized_coords.append((lon, lat))
else:
# 如果不在多边形内,将其调整到最近的多边形边界
adjusted_lon, adjusted_lat = adjust_to_polygon_boundary([lon, lat], polygons)
optimized_coords.append((adjusted_lon, adjusted_lat))
return optimized_coords
async def process_school_coordinates():
"""
优化t_school表中现有学校坐标分布
按县区和学段分组处理,美化坐标分布
注意:这是一个补丁程序,只修改现有坐标,不重置为0
"""
print("\n===== 学校坐标分布优化补丁 =====")
print("正在开始优化现有学校坐标分布...")
try:
# 1. 获取所有县区信息
areas = await getAllArea()
print(f"\n✅ 成功获取 {len(areas)} 个县区信息")
# 2. 定义需要处理的学段
education_levels = ["学前", "小学", "初中", "高中"]
# 3. 统计信息
total_schools = 0
processed_schools = 0
failed_counties = 0
# 4. 遍历所有县区
for area in areas:
county_name = area["area_name"]
area_code = str(area["area_code"])[:-3] # 去掉最后三位0
print(f"\n🔄 正在处理: {county_name} (代码: {area_code})")
# 5. 构建GeoJSON文件路径
geojson_path = f"d:\\dsWork\\YunNanProject\\Init\\Json\\{area_code}.json"
try:
# 6. 加载县区边界数据
polygons = load_geojson(geojson_path)
print(f"✅ 成功加载 {county_name} 边界数据")
# 7. 遍历所有学段
for education_level in education_levels:
print(f" 📚 学段: {education_level}")
try:
# 8. 获取该县区该学段的学校列表及其现有坐标
schools = await get_schools_by_county_and_level(county_name, education_level)
if not schools:
print(f" ⏭️ 该学段无待处理学校")
continue
# 过滤掉没有坐标的学校
schools_with_coords = [s for s in schools if s["longitude"] is not None and s["latitude"] is not None]
if not schools_with_coords:
print(f" ⏭️ 该学段没有有效坐标的学校")
continue
print(f" 📋 找到 {len(schools_with_coords)} 所需要优化坐标的学校")
total_schools += len(schools_with_coords)
# 9. 优化学校坐标分布
optimized_coords = optimize_school_distribution(schools_with_coords, polygons)
# 10. 更新学校坐标
for i, (school, (lon, lat)) in enumerate(zip(schools_with_coords, optimized_coords), 1):
org_name = school["org_name"]
result = await updateSchool(org_name, lon, lat)
if result:
processed_schools += 1
print(f" ✅ {i}/{len(schools_with_coords)} 已优化: {org_name}")
else:
print(f" ❌ {i}/{len(schools_with_coords)} 优化失败: {org_name}")
except Exception as e:
print(f" ⚠️ 处理{education_level}学段时出错: {str(e)}")
continue
except FileNotFoundError:
print(f"❌ 未找到 {county_name} 的GeoJSON文件: {geojson_path}")
failed_counties += 1
except Exception as e:
print(f"❌ 处理{county_name}时出错: {str(e)}")
failed_counties += 1
continue
# 12. 输出统计结果
print("\n" + "="*50)
print("🎯 批量处理完成!")
print(f"📊 统计信息:")
print(f" - 总县区数: {len(areas)}")
print(f" - 成功处理县区数: {len(areas) - failed_counties}")
print(f" - 失败县区数: {failed_counties}")
print(f" - 待处理学校总数: {total_schools}")
print(f" - 成功更新学校数: {processed_schools}")
print("="*50)
except Exception as e:
print(f"\n❌ 批量处理过程中发生错误: {str(e)}")
import traceback
traceback.print_exc()
raise
finally:
# 确保在正常执行和异常情况下都关闭数据库连接
if 'db' in globals() and hasattr(db, 'engine') and db.engine:
try:
await db.engine.dispose()
print("\n✅ 数据库连接已成功关闭")
except Exception as dispose_error:
print(f"\n❌ 关闭数据库连接时出错: {str(dispose_error)}")
if __name__ == "__main__":
# 直接开始批量处理 - 为所有学校生成坐标
try:
asyncio.run(process_school_coordinates())
except KeyboardInterrupt:
print("\n⚠️ 程序被用户中断")
except Exception as e:
# 捕获并处理所有异常,包括asyncio和SSL相关错误
print(f"\n❌ 程序执行出错: {str(e)}")
print(" 错误类型:", type(e).__name__)
# 仅在需要详细调试时显示完整堆栈
print("\n详细错误信息:")
import traceback
traceback.print_exc()
finally:
print("\n程序已退出")

View File

@@ -0,0 +1,89 @@
[
{
"domain": ".amap.com",
"expiry": 1783436001,
"httpOnly": false,
"name": "tfstk",
"path": "/",
"sameSite": "None",
"secure": true,
"value": "gM_oFfTFRg-71NH4Sv874ohkZ_ZAwUTBK93ppepU0KJXJW9RTebhp6-FyUCRnBWVhe3JzeUHKTWM99fQNBJFL9vd2oUON_TB8RB3WPC5mpKEaTAE8sPe1BAUM3zSaLiH8ReTkUIGoi8Fw8wZHn52hBAEapWe0sRH3QReY9J20CRtUe8FLSv29Bkr8LuUumR93p8e8954iBp2ae8FLsPDObOaUL7FyZyL5IXlLBGRuQxkQ_JPw1bqo3o5OK0E8NSDqdupn2uFoQ5FNSuqSkpNfLK9ptzxref2tTpAQPuDuhj19BXur48R0i_WyZeip3YvehSem7zPiUvkjaKi1mLlYT_HVgcQ1s8PhG964owXiaQ9xdtm385AiL-GxTej8LIGgtxOkx39RtsFLBXqEgoq0coMm2O49ZoIA3RXidFP3WmOJLCWuSVmjptyGQJTiSmIA3RXidF0icYM4IOyB"
},
{
"domain": ".amap.com",
"httpOnly": true,
"name": "oauth_state",
"path": "/",
"sameSite": "None",
"secure": true,
"value": "e88390e153019eb5fdc2a6a361b08407"
},
{
"domain": ".amap.com",
"expiry": 1775660027,
"httpOnly": true,
"name": "passport_login",
"path": "/",
"sameSite": "None",
"secure": true,
"value": "MTIyMDQwNjY0MSxhbWFwMjc1OWtPR0wsdGtsaXFqZGE0dDNnbnRrY3dpbHFtZG1nbXdncGE2ZjUsMTc2Nzg4NDAyNyxOVEJpT1dRMVlqaGlOemRsWVRZeE4yRTBNVGswWkRreU5tVTBNVFF5WW1FPQ%3D%3D"
},
{
"domain": ".amap.com",
"expiry": 1768143203,
"httpOnly": false,
"name": "xlly_s",
"path": "/",
"sameSite": "None",
"secure": true,
"value": "1"
},
{
"domain": ".amap.com",
"expiry": 1802444002,
"httpOnly": false,
"name": "cna",
"path": "/",
"sameSite": "None",
"secure": true,
"value": "4rLmIViEnl4CAXzrzjwC3UGc"
},
{
"domain": "lbs.amap.com",
"expiry": 1802444003,
"httpOnly": false,
"name": "_uab_collina",
"path": "/tools",
"sameSite": "Lax",
"secure": false,
"value": "176788400312910602225889"
},
{
"domain": ".lbs.amap.com",
"httpOnly": false,
"name": "HMACCOUNT",
"path": "/",
"sameSite": "Lax",
"secure": false,
"value": "DAC02E19E8B24CB5"
},
{
"domain": ".lbs.amap.com",
"httpOnly": false,
"name": "Hm_lpvt_c8ac07c199b1c09a848aaab761f9f909",
"path": "/",
"sameSite": "Lax",
"secure": false,
"value": "1767884002"
},
{
"domain": ".lbs.amap.com",
"expiry": 1799420002,
"httpOnly": false,
"name": "Hm_lvt_c8ac07c199b1c09a848aaab761f9f909",
"path": "/",
"sameSite": "Lax",
"secure": false,
"value": "1767884002"
}
]

145
Apps/XinDianTu/Coord.py Normal file
View File

@@ -0,0 +1,145 @@
import asyncio
import os
import sys
import logging
from sqlalchemy import text
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# python -m pip install webdriver-manager selenium
# https://googlechromelabs.github.io/chrome-for-testing/
# https://storage.googleapis.com/chrome-for-testing-public/143.0.7499.192/win64/chromedriver-win64.zip
# update t_station_profile_scd set coord_x=null,coord_y=null where id='95783932-1d32-48e4-9a38-6a6696a6a423'
# 将项目根目录添加到系统路径,确保可以导入 Util, DbKit 和 Config 模块
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from Util.PaChongGaoDeKit import PaChongGaoDeKit
from DbKit.Db import Db
from Config.Config import DB_URL
class CookieInvalidError(Exception):
"""自定义异常:表示 Cookie 无效或过期"""
pass
async def main(check_cookie_first=True):
"""
坐标回填任务:自动扫描数据库中缺失坐标的场站地址,并通过高德地图补全。
:param check_cookie_first: 是否在开始前验证 Cookie 有效性
"""
logger.info("正在启动 T4_Coord: 高德地图坐标回填任务...")
# 初始化数据库连接
db = Db(db_url=DB_URL)
await db.init_db()
# 初始化高德地图工具类(使用无头模式)
# 显式指定 Cookie 路径,确保跨目录运行时能找到文件
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
cookie_path = os.path.join(project_root, 'Json', 'amap_cookies.json')
if not os.path.exists(cookie_path):
logger.error(f"错误:未找到 Cookie 文件:{cookie_path}")
# 如果要求检查且文件不存在,直接抛出异常以便上层处理
if check_cookie_first:
await db.close()
raise CookieInvalidError("Cookie file not found")
logger.error("请先运行 Tools/T3_GetAmapCookie.py 生成有效 Cookie。")
return
kit = PaChongGaoDeKit(cookie_file=cookie_path, headless=False)
try:
# 0. Cookie 有效性探针
if check_cookie_first:
logger.info("正在验证高德地图 Cookie 有效性...")
loop = asyncio.get_event_loop()
# 使用一个肯定存在的地址进行测试
test_addr = "北京市天安门"
t_lng, t_lat = await loop.run_in_executor(None, kit.get_coordinate, test_addr)
if not t_lng:
logger.warning(f"Cookie 验证失败:无法获取测试地址 '{test_addr}' 的坐标。")
logger.warning("判定 Cookie 已失效或过期。")
raise CookieInvalidError("Cookie invalid or expired")
else:
logger.info(f"Cookie 验证通过 (测试坐标: {t_lng}, {t_lat})。")
async with await db.get_session() as session:
# 1. 查询所有地址不为空但缺失坐标的唯一地址
sql_missing = """
SELECT DISTINCT address
FROM t_station_profile_scd
WHERE address IS NOT NULL
AND address != ''
AND (coord_x IS NULL OR coord_y IS NULL)
"""
result = await session.execute(text(sql_missing))
missing_addresses = [row[0] for row in result.fetchall()]
logger.info(f"共发现 {len(missing_addresses)} 个缺失坐标的唯一地址。")
for address in missing_addresses:
logger.info(f"\n正在处理地址: {address}")
# 2. 检查数据库中其他记录是否已经存有该地址的坐标(去重复查询)
# 这样可以最大程度减少对高德地图接口的调用
sql_check = """
SELECT coord_x, coord_y
FROM t_station_profile_scd
WHERE address = :addr
AND coord_x IS NOT NULL
AND coord_y IS NOT NULL
LIMIT 1
"""
existing = await session.execute(text(sql_check), {"addr": address})
row = existing.fetchone()
lng, lat = None, None
if row:
lng, lat = row[0], row[1]
logger.info(f" -> 在数据库中找到现有坐标: {lng}, {lat}")
else:
# 3. 数据库中没有,调用高德地图进行查询
logger.info(" -> 数据库中无记录,正在查询高德地图...")
# 在线程池中运行同步的 Selenium 调用,避免阻塞异步循环
loop = asyncio.get_event_loop()
lng, lat = await loop.run_in_executor(None, kit.get_coordinate, address)
if lng and lat:
logger.info(f" -> 高德地图返回坐标: {lng}, {lat}")
else:
logger.info(" -> 高德地图未返回有效坐标。")
# 4. 如果获取到了坐标,更新数据库中所有匹配该地址的记录
if lng and lat:
sql_update = """
UPDATE t_station_profile_scd
SET coord_x = :lng, coord_y = :lat
WHERE address = :addr
"""
await session.execute(text(sql_update), {
"lng": lng,
"lat": lat,
"addr": address
})
await session.commit()
logger.info(f" -> 已成功更新数据库中地址为 '{address}' 的记录。")
else:
logger.info(f" -> 跳过更新 (未找到有效坐标)。")
except Exception as e:
logger.error(f"执行过程中出错: {e}")
finally:
# 释放资源
kit.close()
await db.close()
logger.info("\n任务执行结束。")
if __name__ == "__main__":
# 针对 Windows 平台的异步策略设置
if sys.platform == 'win32':
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
asyncio.run(main())

581
Apps/XinDianTu/Crawler.py Normal file
View File

@@ -0,0 +1,581 @@
# coding=utf-8
import asyncio
import logging
import uuid
import os
import sys
import json
import time
import hashlib
from PIL import Image
# 将项目根目录添加到 sys.path
project_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
if project_root not in sys.path:
sys.path.append(project_root)
import uiautomator2 as u2
from Core.BaseCrawler import BaseCrawler
from Util import Kit
from Util.Kit import take_screenshot
from Util.ObsUtil import ObsUploader
from Util.RedisKit import RedisKit
from Util.PaddleOCRKit import get_ocr_kit
import cv2
from Apps.XinDianTu.Service import XinDianTuService
from Config.Config import (
OBS_TMP_PREFIX, CDN_DOMAIN, SCROLL_DISTANCE_RATIO,
MAX_SCROLLS, REDIS_STATION_EXPIRE,
WAIT_DETAIL_PAGE_LOAD, WAIT_BACK_TO_LIST, WAIT_AFTER_SCROLL,
MAX_CRAWL_DISTANCE
)
# --- 用户配置区域 ---
# 是否保留截图文件(True=保留备查,False=随用随删)
KEEP_SCREENSHOTS = True
# [Testing] 是否在启动时清除 Redis 中的场站处理记录
# True: 每次运行前清除记录,方便反复测试同一个场站
# False: 生产模式,保留记录以避免重复爬取
TEST_CLEAR_REDIS = True
# 配置说明:
# SCROLL_DISTANCE_RATIO 控制翻页时的滑动距离(在 Config.py 中修改)。
# 对于华为 Mate 20x 等分辨率较低或屏幕较小的手机,如果发现翻页时跳过了某些场站,
# 请尝试减小 SCROLL_DISTANCE_RATIO(例如设置为 0.4 或 0.3)。
# 这样每次滑动的距离变短,可以确保所有场站都能被完整显示并识别。
# 配置日志输出
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(sys.stdout)
]
)
logger = logging.getLogger("StationList")
# 强制设置所有相关模块的日志级别为 INFO,防止被第三方库干扰
logging.getLogger("PaddleOCRKit").setLevel(logging.INFO)
logging.getLogger("Util.Kit").setLevel(logging.INFO)
logging.getLogger("OpenXinDianTu").setLevel(logging.INFO)
logging.getLogger("FullProcess").setLevel(logging.INFO)
class XinDianTuCrawler(BaseCrawler):
"""
新电途小程序爬虫实现
"""
def __init__(self, service=None):
super().__init__(service)
# 初始化配置参数
self.max_scrolls = MAX_SCROLLS
self.uploader = ObsUploader()
self.redis_kit = RedisKit()
async def start(self):
"""
实现 BaseCrawler 的启动入口
"""
# 兼容旧逻辑,直接调用 main
await main(self.service)
async def open_app(self):
# 实际逻辑在 Opener.py,此处可作为封装层
pass
async def crawl_list(self):
# 实际逻辑在 get_station_list,此处可作为封装层
pass
async def crawl_detail(self, station_info):
pass
async def get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS):
"""
获取场站列表并处理翻页
"""
all_stations = []
seen_names = set()
redis_kit = RedisKit()
# 确保在“找桩”或者首页列表界面
window_size = d.window_size()
w, h = window_size[0], window_size[1]
# 获取设备信息以用于坐标计算
device_info = d.info
device_info['width'] = w
device_info['height'] = h
logger.info(f"设备信息: {device_info.get('productName')} | 窗口分辨率: {w}x{h}")
max_distance_reached = False
stop_reason = "max_scrolls_reached" # 默认停止原因:达到最大翻页次数
# 存储后台异步任务
background_tasks = []
for i in range(max_scrolls + 1):
if max_distance_reached:
logger.info("已达到最大抓取距离,停止翻页。")
stop_reason = "max_distance_reached"
break
logger.info(f"正在处理第 {i + 1} 页...")
# 1. 拍摄截图
logger.info(f"Step [1/6] 正在拍摄列表页截图...")
t_shot = time.time()
image_uuid = str(uuid.uuid4())
# 使用相对路径: 基于当前脚本目录下的 Images 文件夹
base_dir = os.path.dirname(os.path.abspath(__file__))
save_dir = os.path.join(base_dir, "./Images")
screenshot_path = take_screenshot(d, image_uuid, save_dir=save_dir)
logger.info(f"Step [1/6] 列表页截图已完成: {screenshot_path} (耗时: {time.time() - t_shot:.2f}s)")
# 校验截图实际尺寸
img_w, img_h = 0, 0
try:
with Image.open(screenshot_path) as img:
img_w, img_h = img.size
logger.info(f"截图实际尺寸: {img_w}x{img_h}")
# 如果截图尺寸与窗口尺寸不符,更新 device_info 以适配坐标换算
if img_w != w or img_h != h:
logger.warning(f"检测到截图尺寸 ({img_w}x{img_h}) 与窗口尺寸 ({w}x{h}) 不一致,将以截图尺寸为准进行坐标换算。")
device_info['width'] = img_w
device_info['height'] = img_h
except Exception as e:
logger.warning(f"无法获取截图尺寸信息: {e}")
# 2. 使用 Kit.crop_cards_from_image 进行切片和生成 _vl.jpg
logger.info("Step [2/6] 正在执行本地图形学切片分析,识别场站卡片并生成绿色方框图...")
t_crop = time.time()
# 这会生成 _vl.jpg 和 .json
Kit.crop_cards_from_image(screenshot_path)
logger.info(f"Step [2/6] 图形学切片分析完成 (耗时: {time.time() - t_crop:.2f}s)")
# 读取生成的元数据
json_path = screenshot_path.replace(".jpg", ".json")
vl_img_path = screenshot_path.replace(".jpg", "_vl.jpg")
if not os.path.exists(json_path) or not os.path.exists(vl_img_path):
logger.error(f"❌ Step [2/6] 失败: 未生成有效的 JSON 或 VL 图片 (JSON: {os.path.exists(json_path)}, VL: {os.path.exists(vl_img_path)})")
logger.warning("可能未识别到任何卡片,跳过当前页")
continue
logger.info(f"Step [3/6] 绿色方框图已生成: {vl_img_path}")
with open(json_path, 'r', encoding='utf-8') as f:
json_metadata = json.load(f)
# --- New Logic: Local OCR via PaddleOCRKit ---
logger.info("Step [4/6] 正在开始本地 OCR 识别流程,将识别出的卡片逐一交给 OCR...")
t_ocr_total = time.time()
stations = []
try:
ocr_kit = get_ocr_kit()
# 读取原始截图进行裁剪 (比读取 _vl.jpg 更干净,没有绿框干扰)
original_img = cv2.imread(screenshot_path)
if "cards" in json_metadata and original_img is not None:
h_img, w_img = original_img.shape[:2]
num_cards = len(json_metadata['cards'])
logger.info(f"检测到 {num_cards} 个场站卡片,准备开始逐一 OCR 识别...")
for idx, card in enumerate(json_metadata["cards"]):
# card: {"id": 1, "rect": [x1, y1, x2, y2], "click_point": [cx, cy]}
rect = card.get("rect")
if not rect: continue
x1, y1, x2, y2 = rect
# 边界检查
x1 = max(0, min(x1, w_img))
x2 = max(0, min(x2, w_img))
y1 = max(0, min(y1, h_img))
y2 = max(0, min(y2, h_img))
if x2 <= x1 or y2 <= y1: continue
# 裁剪卡片
cropped_card = original_img[y1:y2, x1:x2]
# 识别
logger.info(f"正在识别第 {idx+1}/{num_cards} 个卡片 (区域: {rect})...")
t_card_start = time.time()
parsed_data = ocr_kit.recognize(cropped_card)
t_card_end = time.time()
# Log detailed OCR result
logger.info(f"卡片 {idx+1} OCR 识别耗时: {t_card_end - t_card_start:.2f}s")
if parsed_data and parsed_data.get("station_name"):
# 格式化数据以匹配原有结构
piles_list = parsed_data.get("piles", [])
piles_str_parts = []
for p in piles_list:
p_type = p.get("type", "")
p_free = p.get("free", 0)
p_total = p.get("total", 0)
piles_str_parts.append(f"{p_type}:{p_free}/{p_total}")
piles_str = " ".join(piles_str_parts)
station_info = {
"station_name": parsed_data.get("station_name"),
"price": str(parsed_data.get("price")) if parsed_data.get("price") is not None else "",
"pro_price": "", # 暂未解析
"piles": piles_str,
"distance": parsed_data.get("distance", ""),
"uia_center_x": card["click_point"][0],
"uia_center_y": card["click_point"][1],
"tags": parsed_data.get("tags", []),
"parking_info": parsed_data.get("parking", "")
}
logger.info(f"✅ 成功解析场站: {station_info['station_name']} | 距离: {station_info['distance']}")
# 立即记录场站基础状态信息 (电桩、价格等)
try:
await service.record_station_status(station_info)
except Exception as e:
logger.error(f"❌ 记录场站状态失败: {e}")
stations.append(station_info)
else:
logger.warning(f"⚠️ 卡片 {idx+1} 未能识别出有效场站名称")
except Exception as e:
logger.error(f"❌ Local OCR processing failed: {e}", exc_info=True)
logger.info(f"Step [5/6] 本地 OCR 解析完成 (总耗时: {time.time() - t_ocr_total:.2f}s) | 成功识别: {len(stations)}/{len(json_metadata.get('cards', []))}")
if not stations:
logger.warning("当前页面未检测到任何有效的场站信息")
else:
logger.info(f"Step [6/6] 准备开始逐一点击场站进入详情页 (共 {len(stations)} 个)...")
new_stations_found = False
for s_idx, s in enumerate(stations):
name = s.get("station_name")
# 过滤掉明显的非场站名称
if not name:
continue
# --- 短期去重:检查 Redis 缓存 ---
# 2分钟内如果处理过该场站,则跳过
redis_key = f"processed_station:{name}"
if await redis_kit.exists(redis_key):
logger.info(f"场站 {name} 在 2 分钟内已处理过,跳过")
continue
if name in seen_names:
continue
# 使用从 JSON 元数据中恢复的点击坐标
x = s.get("uia_center_x")
y = s.get("uia_center_y")
if x is None or y is None:
logger.warning(f"场站 {name} 缺少坐标信息,无法进入详情页")
continue
seen_names.add(name)
all_stations.append(s)
new_stations_found = True
# 打印基本信息
price = s.get("price")
pro_price = s.get("pro_price")
piles_str = s.get("piles", "")
distance_str = s.get("distance", "")
# 检查距离
dist_log_part = ""
if distance_str:
try:
import re
dist_match = re.search(r"(\d+(\.\d+)?)", distance_str)
if dist_match:
dist_val = float(dist_match.group(1))
is_km = "km" in distance_str.lower()
dist_km = dist_val if is_km else dist_val / 1000.0
# 将距离解析结果和限制检查合并到日志中
dist_log_part = f" (解析: {dist_km:.2f}km / 限: {MAX_CRAWL_DISTANCE}km)"
if dist_km >= MAX_CRAWL_DISTANCE:
logger.info(f"当前场站 {name} 距离 {dist_km}km (原始: {distance_str}) 已达到或超过限制 {MAX_CRAWL_DISTANCE}km。")
max_distance_reached = True
except Exception as e:
logger.warning(f"解析距离出错: {distance_str}, {e}")
pro_info = f" | PRO会员价: {pro_price}" if pro_price else ""
# 确保距离始终显示,如果为空则显示 "未知"
display_distance = distance_str if distance_str else "未知"
logger.info(f"正在处理第 {s_idx+1} 个场站: {name} | 价格: {price}{pro_info} | 枪: {piles_str} | 距离: {display_distance}{dist_log_part}")
if max_distance_reached:
break
# --- 点击进入详情页记录地址 ---
# 使用相对坐标点击,以适应不同分辨率
if img_w > 0 and img_h > 0:
rel_x = max(0.0, min(1.0, x / img_w))
rel_y = max(0.0, min(1.0, y / img_h))
logger.info(f"👉 正在点击进入详情页: {name} (相对坐标: {rel_x:.3f}, {rel_y:.3f})")
d.click(rel_x, rel_y)
else:
logger.info(f"👉 正在点击进入详情页: {name} (绝对坐标: {x}, {y})")
d.click(x, y)
await asyncio.sleep(WAIT_DETAIL_PAGE_LOAD) # 等待详情页加载
# 尝试获取并保存地址
address_recorded = False
for retry in range(2):
# 1. 拍摄详情页截图
t_d_shot = time.time()
detail_uuid = str(uuid.uuid4())
detail_screenshot_path = take_screenshot(d, detail_uuid, save_dir=save_dir)
logger.info(f"Step [详情页截图] 耗时: {time.time() - t_d_shot:.4f}s")
# 2. 上传至 OBS
t_d_up = time.time()
detail_object_key = f"{OBS_TMP_PREFIX}/{detail_uuid}.jpg"
up_success, _ = uploader.upload_file(detail_object_key, detail_screenshot_path)
logger.info(f"Step [上传详情页截图] 耗时: {time.time() - t_d_up:.4f}s")
if up_success:
detail_url = f"https://{CDN_DOMAIN}/{detail_object_key}"
# 3. 解析并保存地址 (异步后台处理)
# 定义异步任务函数
async def _process_address_task(task_name, task_url, task_dev_info, task_redis_key):
t_addr_task = time.time()
try:
addr_res = await service.process_station_address(task_name, task_url, device_info=task_dev_info)
logger.info(f"Step [解析并保存地址-后台] 耗时: {time.time() - t_addr_task:.4f}s")
if addr_res.get("address"):
logger.info(f"成功记录地址: {addr_res.get('address')}")
# 记录到 Redis,设置过期时间
await redis_kit.set_data(task_redis_key, "1", expire=REDIS_STATION_EXPIRE)
except Exception as e:
logger.error(f"后台处理地址失败 ({task_name}): {e}")
# 创建并启动任务
addr_task = asyncio.create_task(_process_address_task(name, detail_url, device_info, redis_key))
background_tasks.append(addr_task)
logger.info(f"已提交地址解析任务到后台: {name}")
# 标记为已记录以跳出 retry 循环
address_recorded = True
# 清理详情页临时截图
if not KEEP_SCREENSHOTS and os.path.exists(detail_screenshot_path):
os.remove(detail_screenshot_path)
break # 直接跳出 retry 循环
# --- 抓取价格时段信息 (New Feature) ---
# 仅在测试模式下或特定需求下启用
try:
logger.info(f"开始抓取价格时段信息: {name}")
# 使用几何特征识别 "全部时段" 按钮
# 临时截图
temp_uuid = "temp_find_expand"
screenshot_path = take_screenshot(d, temp_uuid, save_dir="Temp")
# 尝试识别,将调试图片保存到 Images 目录
t_find = time.time()
pos = Kit.find_expand_button_position(screenshot_path, debug_dir=save_dir, debug_filename_prefix=name)
logger.info(f"Step [识别展开按钮] 耗时: {time.time() - t_find:.4f}s")
# 清理截图
try:
os.remove(screenshot_path)
except:
pass
if pos:
x, y = pos
logger.info(f"通过几何特征找到 '全部时段' 按钮: ({x}, {y})")
d.click(x, y)
await asyncio.sleep(1.5)
# 抓取3屏截图
price_image_urls = []
for p_idx in range(1, 4):
# 使用场站名称的MD5值作为文件名前缀,避免中文和特殊字符导致的URL问题
t_p_loop = time.time()
name_md5 = hashlib.md5(name.encode('utf-8')).hexdigest()
p_uuid = f"{name_md5}_price_{p_idx}"
p_path = take_screenshot(d, p_uuid, save_dir=save_dir)
logger.info(f"已保存价格时段截图 {p_idx} (Station: {name}, MD5: {name_md5}): {p_path}")
# 上传到 OBS
p_object_key = f"{OBS_TMP_PREFIX}/{p_uuid}.jpg"
success, _ = uploader.upload_file(p_object_key, p_path)
if success:
p_url = f"https://{CDN_DOMAIN}/{p_object_key}"
price_image_urls.append(p_url)
logger.info(f"截图上传成功: {p_url}")
else:
logger.warning(f"截图上传失败: {p_path}")
logger.info(f"Step [价格截图与上传-{p_idx}] 耗时: {time.time() - t_p_loop:.4f}s")
if p_idx < 3:
d.swipe_ext("up", scale=0.7)
await asyncio.sleep(1.0)
# 调用服务解析价格时段 (异步后台处理)
if price_image_urls:
logger.info(f"正在调用接口解析电费时段数据 ({len(price_image_urls)} 张图片) - 转入后台...")
async def _process_price_task(task_name, task_urls, task_dev_info):
t_price_task = time.time()
try:
await service.process_price_schedule(task_name, task_urls, device_info=task_dev_info)
logger.info(f"Step [解析电费时段数据-后台] 耗时: {time.time() - t_price_task:.4f}s")
logger.info(f"电费时段数据解析完成: {task_name}")
except Exception as e:
logger.error(f"后台处理价格时段失败 ({task_name}): {e}")
price_task = asyncio.create_task(_process_price_task(name, price_image_urls, device_info))
background_tasks.append(price_task)
else:
logger.warning("未能获取到有效的价格时段截图,跳过解析")
else:
logger.warning(f"未能通过几何特征识别 '全部时段' 按钮")
except Exception as e:
logger.warning(f"抓取价格时段信息时发生异常: {e}")
# 测试模式:仅处理第一个场站
# logger.info("测试模式:仅处理第一个场站,即将退出 (Y3)")
# return all_stations
# 返回列表页
d.press("back")
await asyncio.sleep(WAIT_BACK_TO_LIST) # 等待列表页重新稳定
if not new_stations_found and i > 0:
logger.info("未发现更多新场站,停止下拉。")
# 如果需要强行爬取所有,可以注释掉 break
# break
# 5. 如果还没到最后一页,执行下拉
if i < max_scrolls and not max_distance_reached:
logger.info(f"执行下拉翻页 (距离比例: {SCROLL_DISTANCE_RATIO})...")
# 根据配置的比例计算滑动起始和终点
# 保证滑动在屏幕中心区域进行
start_y = 0.5 + (SCROLL_DISTANCE_RATIO / 2)
end_y = 0.5 - (SCROLL_DISTANCE_RATIO / 2)
d.swipe(w * 0.5, h * start_y, w * 0.5, h * end_y, duration=0.5)
await asyncio.sleep(WAIT_AFTER_SCROLL) # 等待页面加载和列表稳定
# 清理列表页截图
if not KEEP_SCREENSHOTS:
if os.path.exists(screenshot_path):
os.remove(screenshot_path)
if os.path.exists(vl_img_path):
os.remove(vl_img_path)
if os.path.exists(json_path):
os.remove(json_path)
logger.info(f"爬取任务结束,正在等待 {len(background_tasks)} 个后台处理任务完成...")
if background_tasks:
await asyncio.gather(*background_tasks)
logger.info("所有后台任务已完成。")
logger.info(f"任务结束,共采集到 {len(all_stations)} 个场站。停止原因: {stop_reason}")
return all_stations
def clean_images_dir():
"""清理 Images 目录下除保留文件外的所有文件"""
base_dir = os.path.dirname(os.path.abspath(__file__))
images_dir = os.path.join(base_dir, "Images")
if not os.path.exists(images_dir):
return
keep_files = {'1.jpg', '2.jpg', '3.jpg', '4.jpg'}
logger.info(f"正在清理 Images 目录 (保留: {keep_files})...")
deleted_count = 0
for filename in os.listdir(images_dir):
if filename in keep_files:
continue
file_path = os.path.join(images_dir, filename)
try:
if os.path.isfile(file_path):
os.remove(file_path)
deleted_count += 1
except Exception as e:
logger.warning(f"删除文件失败 {filename}: {e}")
logger.info(f"清理完成,共删除 {deleted_count} 个文件。")
async def clean_redis_data(redis_kit):
"""清除 Redis 中的场站处理记录"""
if TEST_CLEAR_REDIS:
logger.info("测试模式开启:正在清除 Redis 中的场站处理记录 (processed_station:*)...")
keys = await redis_kit.keys("processed_station:*")
if keys:
count = await redis_kit.delete(*keys)
logger.info(f"已清除 {count} 条场站处理记录")
else:
logger.info("未发现旧的场站处理记录")
async def main(service=None, do_cleanup=True):
# 连接设备
d = u2.connect()
# 初始化服务
should_close_service = False
if service is None:
service = XinDianTuService()
should_close_service = True
# 初始化数据库连接(XinDianTuService 需要)
await service.init_db()
uploader = ObsUploader()
redis_kit = RedisKit()
try:
if do_cleanup:
# 清理图片目录
clean_images_dir()
# [Testing] 如果配置为测试模式,启动时清除 Redis 记录
await clean_redis_data(redis_kit)
# 清理过期数据
# await service.cleanup_old_data()
# 获取场站列表
stations = await get_station_list(d, service, uploader, max_scrolls=MAX_SCROLLS)
if stations:
logger.info("场站列表采集完成。")
else:
logger.warning("未采集到任何场站信息。")
return True
except Exception as e:
logger.exception(f"运行过程中出现异常: {e}")
return False
finally:
# 关闭数据库连接
if should_close_service:
await service.close_db()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
logger.info("程序被用户中断.")
except Exception as e:
logger.exception(f"程序崩溃: {e}")

View File

@@ -0,0 +1,64 @@
import os
import sys
import logging
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
# pip install selenium
# 将项目根目录添加到系统路径,确保可以导入 Util 模块
# 注意:PaChongGaoDeKit 位于 Util 目录下
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.join(current_dir, '..'))
from Util.PaChongGaoDeKit import PaChongGaoDeKit
def run_login_tool(force=False):
"""
运行高德地图 Cookie 获取工具。
:param force: 如果为 True,则跳过 'Cookie 已存在' 的询问,直接开始流程。
:return: Boolean, True 表示获取成功, False 表示失败或取消。
"""
logger.info("正在启动高德地图 Cookie 获取工具,使用手机号18686619970+验证码登录即可!")
# 默认将 Cookie 保存到项目根目录下的 Json/amap_cookies.json
cookie_path = os.path.join(current_dir, '../Json/amap_cookies.json')
# 检查 Cookie 文件是否已存在
if os.path.exists(cookie_path) and not force:
logger.info(f"提示:检测到已存在 Cookie 文件: {os.path.abspath(cookie_path)}")
logger.info("如果您本地的 Cookie 文件没有过期且可以正常使用,则不需要重新生成。")
user_choice = input("是否仍要继续运行并重新获取 Cookie?(y/n): ").strip().lower()
if user_choice != 'y':
logger.info("用户取消操作,程序退出。")
return False
logger.info("\n" + "-"*60)
logger.info("即将开始获取 Cookie,请按照以下步骤操作:")
logger.info("1. 程序将自动打开一个 Chrome 浏览器窗口。")
logger.info("2. 请在该窗口中完成高德地图账号登录(扫码或账号密码)。")
logger.info("3. 登录成功后,保持页面处于控制台或个人中心状态。")
logger.info("4. 回到此终端窗口,按下回车键,程序将保存 Cookie 并自动关闭浏览器。")
logger.info("-"*60 + "\n")
# 初始化工具类,强制开启非无头模式(显示浏览器窗口)以便手动登录
# 确保 Json 目录存在
os.makedirs(os.path.dirname(cookie_path), exist_ok=True)
kit = PaChongGaoDeKit(headless=False, cookie_file=cookie_path)
try:
# 执行登录并保存流程
return kit.login_and_save_cookies()
except Exception as e:
logger.error(f"执行过程中发生意外错误: {e}")
return False
finally:
# 关闭浏览器资源
kit.close()
logger.info("工具运行结束。")
if __name__ == "__main__":
run_login_tool()

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View File

@@ -0,0 +1,20 @@
{
"image": "04b5e613-8e21-408b-ae93-b878a6126844.jpg",
"width": 720,
"height": 202,
"cards": [
{
"id": 1,
"rect": [
19,
226,
720,
428
],
"click_point": [
124,
266
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -0,0 +1,59 @@
{
"image": "04fb17c6-124c-4661-9c50-ead044d94ebc.jpg",
"width": 720,
"height": 259,
"cards": [
{
"id": 1,
"rect": [
19,
404,
700,
581
],
"click_point": [
121,
439
]
},
{
"id": 2,
"rect": [
19,
595,
700,
844
],
"click_point": [
121,
644
]
},
{
"id": 3,
"rect": [
19,
852,
700,
1101
],
"click_point": [
121,
901
]
},
{
"id": 4,
"rect": [
19,
1115,
700,
1374
],
"click_point": [
121,
1166
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

View File

@@ -0,0 +1,46 @@
{
"image": "05af72de-43b4-445f-9435-935bf4a3f9fb.jpg",
"width": 720,
"height": 140,
"cards": [
{
"id": 1,
"rect": [
19,
428,
700,
677
],
"click_point": [
121,
477
]
},
{
"id": 2,
"rect": [
19,
690,
700,
965
],
"click_point": [
121,
745
]
},
{
"id": 3,
"rect": [
19,
979,
700,
1228
],
"click_point": [
121,
1028
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

@@ -0,0 +1,46 @@
{
"image": "06ec294c-ce75-4a42-9c15-caaa2d57e60e.jpg",
"width": 720,
"height": 106,
"cards": [
{
"id": 1,
"rect": [
19,
491,
700,
740
],
"click_point": [
121,
540
]
},
{
"id": 2,
"rect": [
19,
748,
700,
997
],
"click_point": [
121,
797
]
},
{
"id": 3,
"rect": [
19,
1011,
700,
1260
],
"click_point": [
121,
1060
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

View File

@@ -0,0 +1,46 @@
{
"image": "07237cb5-4f55-4e7f-8ae6-3a927d046e06.jpg",
"width": 720,
"height": 243,
"cards": [
{
"id": 1,
"rect": [
19,
514,
700,
789
],
"click_point": [
121,
569
]
},
{
"id": 2,
"rect": [
19,
802,
700,
1117
],
"click_point": [
121,
865
]
},
{
"id": 3,
"rect": [
19,
1131,
700,
1374
],
"click_point": [
121,
1179
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 244 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -0,0 +1,46 @@
{
"image": "0724d0ef-941c-4d3c-a911-4b3e84343e52.jpg",
"width": 720,
"height": 249,
"cards": [
{
"id": 1,
"rect": [
19,
515,
700,
764
],
"click_point": [
121,
564
]
},
{
"id": 2,
"rect": [
19,
772,
700,
1021
],
"click_point": [
121,
821
]
},
{
"id": 3,
"rect": [
19,
1035,
700,
1284
],
"click_point": [
121,
1084
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

View File

@@ -0,0 +1,20 @@
{
"image": "072e1a31-67c9-427e-9979-67118ea0df45.jpg",
"width": 720,
"height": 202,
"cards": [
{
"id": 1,
"rect": [
19,
170,
720,
372
],
"click_point": [
124,
210
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Some files were not shown because too many files have changed in this diff Show More