Skip to content

Commit cdfa49d

Browse files
committed
坏的
1 parent bb78781 commit cdfa49d

3 files changed

Lines changed: 291 additions & 9 deletions

File tree

CHANGELOG/v2.3.0/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ v2.3 - Shiroko (砂狼白子) release 1
1313
- 新增 **名单导入**,支持批量导入名单,且能检测并自动重命名重复项
1414
- 新增 **URL/IPC接口**,支持打开计时器窗口
1515
- 新增 **PostHog 产品分析**,支持用户行为统计(启动、点名、抽奖事件上报)及用户属性同步(抽取次数统计)
16+
- 新增 **地区统计**,PostHog 上报中文 GeoIP 地区信息
1617

1718
## 💡 功能优化
1819

app/tools/config.py

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
import platform
1111
import sys
1212
import time
13+
from functools import lru_cache
1314
import psutil
15+
import requests
1416

1517
from PySide6.QtWidgets import QWidget, QFileDialog
1618
from PySide6.QtCore import Qt
@@ -162,6 +164,281 @@ def track_event(event_name: str):
162164
)
163165

164166

167+
def get_geoip_properties_zh_cn(timeout_seconds: float = 1.5) -> dict:
168+
providers = (
169+
("系统位置信息", _get_system_location_properties_zh_cn),
170+
("IP33 位置信息", _get_ip33_location_properties),
171+
)
172+
for provider_name, provider in providers:
173+
properties = provider(timeout_seconds=timeout_seconds)
174+
if properties:
175+
logger.debug(f"获取到{provider_name}: {properties}")
176+
return properties
177+
return {}
178+
179+
180+
_GEOIP_COUNTRY_KEY = "$geoip_country_name"
181+
_GEOIP_CITY_KEY = "$geoip_city_name"
182+
_COUNTRY_NAME_ZH_CN_MAP = {
183+
"China": "中国",
184+
"People's Republic of China": "中国",
185+
"PRC": "中国",
186+
"中华人民共和国": "中国",
187+
}
188+
_COUNTRY_REGION_ZH_CN_MAP = {
189+
"CN": "中国",
190+
"HK": "中国香港",
191+
"MO": "中国澳门",
192+
"TW": "中国台湾",
193+
}
194+
_IP33_AREA_CITY_RE = re.compile(
195+
r"^(?P<prefix>.+?(?:省|自治区|特别行政区|市))(?P<rest>.*)$"
196+
)
197+
198+
199+
def _build_geoip_properties(country: str = "", city: str = "") -> dict:
200+
country = (country or "").strip()
201+
city = (city or "").strip()
202+
203+
country = _COUNTRY_NAME_ZH_CN_MAP.get(country, country)
204+
205+
properties = {}
206+
if country:
207+
properties[_GEOIP_COUNTRY_KEY] = country
208+
if city:
209+
properties[_GEOIP_CITY_KEY] = city
210+
return properties
211+
212+
213+
@lru_cache(maxsize=1)
214+
def _map_country_region_to_zh_cn(country_region: str) -> str:
215+
country_region = (country_region or "").strip().upper()
216+
if not country_region:
217+
return ""
218+
219+
return _COUNTRY_REGION_ZH_CN_MAP.get(country_region, country_region)
220+
221+
222+
def _get_windows_geo_country_region() -> tuple[str, str]:
223+
try:
224+
import ctypes
225+
from ctypes import wintypes
226+
227+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
228+
229+
GEOCLASS_NATION = 16
230+
GEOID_NOT_AVAILABLE = 0
231+
GEO_ISO2 = 4
232+
GEO_FRIENDLYNAME = 8
233+
234+
kernel32.GetUserGeoID.argtypes = [wintypes.DWORD]
235+
kernel32.GetUserGeoID.restype = wintypes.INT
236+
kernel32.GetGeoInfoW.argtypes = [
237+
wintypes.INT,
238+
wintypes.DWORD,
239+
wintypes.LPWSTR,
240+
wintypes.INT,
241+
wintypes.LANGID,
242+
]
243+
kernel32.GetGeoInfoW.restype = wintypes.INT
244+
245+
geo_id = int(kernel32.GetUserGeoID(GEOCLASS_NATION))
246+
if geo_id in (GEOID_NOT_AVAILABLE, -1):
247+
return "", ""
248+
249+
def _get_geoinfo(geo_type: int) -> str:
250+
buf = ctypes.create_unicode_buffer(256)
251+
size = int(kernel32.GetGeoInfoW(geo_id, geo_type, buf, len(buf), 0))
252+
if size <= 0:
253+
return ""
254+
return (buf.value or "").strip()
255+
256+
iso2 = _get_geoinfo(GEO_ISO2).upper()
257+
friendly = _get_geoinfo(GEO_FRIENDLYNAME)
258+
return iso2, friendly
259+
except Exception:
260+
return "", ""
261+
262+
263+
def _get_windows_location_properties_via_winrt_zh_cn(timeout_seconds: float) -> dict:
264+
try:
265+
import concurrent.futures
266+
import asyncio
267+
268+
import winrt.windows.devices.geolocation as geolocation
269+
except Exception:
270+
return {}
271+
272+
timeout_seconds = max(0.2, float(timeout_seconds or 0))
273+
274+
def _worker() -> dict:
275+
async def _get() -> dict:
276+
locator = geolocation.Geolocator()
277+
position = await asyncio.wait_for(
278+
locator.get_geoposition_async(), timeout_seconds
279+
)
280+
address = getattr(position, "civic_address", None)
281+
if address is None:
282+
return {}
283+
284+
city = (getattr(address, "city", "") or "").strip()
285+
state_province = (getattr(address, "state", "") or "").strip()
286+
if not state_province:
287+
state_province = (getattr(address, "state_province", "") or "").strip()
288+
289+
country_region = (
290+
(getattr(address, "country_code", "") or "").strip().upper()
291+
)
292+
country = (getattr(address, "country", "") or "").strip()
293+
294+
if not country and country_region:
295+
country = _map_country_region_to_zh_cn(country_region)
296+
else:
297+
country = _COUNTRY_NAME_ZH_CN_MAP.get(country, country)
298+
299+
if not city:
300+
city = state_province
301+
302+
return _build_geoip_properties(country=country, city=city)
303+
304+
return asyncio.run(_get())
305+
306+
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
307+
future = executor.submit(_worker)
308+
try:
309+
return future.result(timeout=timeout_seconds + 0.3)
310+
except Exception:
311+
return {}
312+
313+
314+
def _get_windows_timezone_key_name() -> str:
315+
try:
316+
import ctypes
317+
from ctypes import wintypes
318+
319+
class _SYSTEMTIME(ctypes.Structure):
320+
_fields_ = [
321+
("wYear", wintypes.WORD),
322+
("wMonth", wintypes.WORD),
323+
("wDayOfWeek", wintypes.WORD),
324+
("wDay", wintypes.WORD),
325+
("wHour", wintypes.WORD),
326+
("wMinute", wintypes.WORD),
327+
("wSecond", wintypes.WORD),
328+
("wMilliseconds", wintypes.WORD),
329+
]
330+
331+
class _TIME_DYNAMIC_ZONE_INFORMATION(ctypes.Structure):
332+
_fields_ = [
333+
("Bias", wintypes.LONG),
334+
("StandardName", wintypes.WCHAR * 32),
335+
("StandardDate", _SYSTEMTIME),
336+
("StandardBias", wintypes.LONG),
337+
("DaylightName", wintypes.WCHAR * 32),
338+
("DaylightDate", _SYSTEMTIME),
339+
("DaylightBias", wintypes.LONG),
340+
("TimeZoneKeyName", wintypes.WCHAR * 128),
341+
("DynamicDaylightTimeDisabled", wintypes.BOOL),
342+
]
343+
344+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
345+
kernel32.GetDynamicTimeZoneInformation.argtypes = [
346+
ctypes.POINTER(_TIME_DYNAMIC_ZONE_INFORMATION)
347+
]
348+
kernel32.GetDynamicTimeZoneInformation.restype = wintypes.DWORD
349+
350+
info = _TIME_DYNAMIC_ZONE_INFORMATION()
351+
kernel32.GetDynamicTimeZoneInformation(ctypes.byref(info))
352+
return (info.TimeZoneKeyName or "").strip()
353+
except Exception:
354+
return ""
355+
356+
357+
def _guess_city_from_timezone(country_region: str, tz_key_name: str) -> str:
358+
country_region = (country_region or "").strip().upper()
359+
tz_key_name = (tz_key_name or "").strip()
360+
if not country_region or not tz_key_name:
361+
return ""
362+
363+
if country_region == "TW" and tz_key_name == "Taipei Standard Time":
364+
return "台北"
365+
366+
if country_region in {"CN", "HK", "MO"} and tz_key_name == "China Standard Time":
367+
return "北京"
368+
369+
return ""
370+
371+
372+
def _get_system_location_properties_zh_cn(timeout_seconds: float = 1.5) -> dict:
373+
if sys.platform != "win32":
374+
return {}
375+
376+
properties = _get_windows_location_properties_via_winrt_zh_cn(
377+
timeout_seconds=timeout_seconds
378+
)
379+
if properties:
380+
return properties
381+
382+
country_region, friendly = _get_windows_geo_country_region()
383+
if not country_region and not friendly:
384+
return {}
385+
386+
country = ()
387+
if not country:
388+
country = friendly
389+
390+
city = ""
391+
if country_region:
392+
city = _guess_city_from_timezone(
393+
country_region, _get_windows_timezone_key_name()
394+
)
395+
396+
return _build_geoip_properties(country=country, city=city)
397+
398+
399+
def _get_ip33_location_properties(timeout_seconds: float = 1.5) -> dict:
400+
headers = {"User-Agent": f"SecRandom/{SPECIAL_VERSION}"}
401+
402+
timeout_seconds = max(0.2, float(timeout_seconds or 0))
403+
for url in ("https://api.ip33.com/ip/search", "http://api.ip33.com/ip/search"):
404+
try:
405+
response = requests.get(url, headers=headers, timeout=timeout_seconds)
406+
response.raise_for_status()
407+
data = response.json()
408+
properties = _parse_ip33_response_to_properties(data)
409+
if properties:
410+
return properties
411+
except Exception:
412+
continue
413+
return {}
414+
415+
416+
def _parse_ip33_response_to_properties(data: dict) -> dict:
417+
if not isinstance(data, dict):
418+
return {}
419+
420+
area = (data.get("area") or "").strip()
421+
if not area:
422+
return {}
423+
424+
area_main = area.split()[0].strip()
425+
if not area_main:
426+
return {}
427+
428+
city = ""
429+
m = _IP33_AREA_CITY_RE.match(area_main)
430+
if m:
431+
rest = (m.group("rest") or "").strip()
432+
if rest:
433+
city = rest
434+
else:
435+
city = (m.group("prefix") or "").strip()
436+
else:
437+
city = area_main
438+
439+
return _build_geoip_properties(country="中国", city=city)
440+
441+
165442
# ==================== 通知模块 ====================
166443

167444

main.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
configure_logging,
1818
set_posthog_client,
1919
create_sentry_before_send_filter,
20+
get_geoip_properties_zh_cn,
2021
)
2122
from app.tools.settings_default import manage_settings_file
2223
from app.tools.settings_access import readme_settings_async, get_or_create_user_id
@@ -85,16 +86,19 @@ def initialize_posthog():
8586
)
8687
set_posthog_client(posthog)
8788
user_id = get_or_create_user_id()
88-
posthog.capture(distinct_id=user_id, event="app_started")
89-
89+
geoip_properties = get_geoip_properties_zh_cn()
9090
total_draw_count, roll_call_total, lottery_total = calculate_total_draw_counts()
9191

92-
posthog.set(
92+
posthog.capture(
9393
distinct_id=user_id,
94+
event="app_started",
9495
properties={
95-
"total_draw_count": total_draw_count,
96-
"roll_call_total_count": roll_call_total,
97-
"lottery_total_count": lottery_total,
96+
"location": geoip_properties,
97+
"$set": {
98+
"total_draw_count": total_draw_count,
99+
"roll_call_total_count": roll_call_total,
100+
"lottery_total_count": lottery_total,
101+
},
98102
},
99103
)
100104

@@ -198,9 +202,9 @@ def initialize_application():
198202
logger.remove()
199203
configure_logging()
200204

201-
if DEV_VERSION not in VERSION:
202-
initialize_sentry()
203-
initialize_posthog()
205+
# if DEV_VERSION not in VERSION:
206+
initialize_sentry()
207+
initialize_posthog()
204208

205209
wm.app_start_time = time.perf_counter()
206210

0 commit comments

Comments
 (0)