WEBSITE:https://www.jessieontheroad.com/
项目背景
澳洲打工度假(WHV)续签需要满足「88 天指定工作」的要求。除了农场和肉厂,其实在偏远或极偏远地区做旅游或酒店相关工作也可以集签。
为了方便自己快速查找能集签的地区,我做了一个 Chrome 扩展,可以批量标注邮编到 Google 地图列表,用地图来辅助选工作、找住宿、规划路线,效率高很多。下面是一些技术细节分享~
它能够:
✅ 输入邮编列表,一键批量标注在 Google 地图中,并保存到指定收藏夹中。
✅ 批量处理多个邮编
✅ 支持“2000至2010”这种范围输入
✅ 自动判断是否已保存,避免重复标注
✅ 将地址保存到指定的 Google Maps 列表中(My Maps)
Chrome 扩展版本
1. 扩展结构
manifest.json # Chrome 插件配置文件 popup.html # 插件弹出窗口 HTML popup.js # 主逻辑脚本
2. Chrome 扩展模块
2.1 popup.js 详细注释拆解
模块一:页面加载时自动获取用户的地图列表
当 popup 页面打开时,自动判断当前是否为 Google 地图页面,如果是,就调用 loadUserLists 加载当前用户的「收藏列表」。
document.addEventListener("DOMContentLoaded", async () => { try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true, }); if (tab.url.includes("google.com/maps")) { await loadUserLists(tab.id); } } catch (error) { console.error("加载列表失败:", error); } });
模块二:从页面中提取用户创建的收藏列表
从地图页面注入 JS 脚本 getUserLists,提取出当前用户创建的收藏列表,并渲染到 <select> 下拉框中供用户选择。
async function loadUserLists(tabId) { try { const result = await chrome.scripting.executeScript({ target: { tabId }, function: getUserLists, }); const lists = result[0].result; const listSelect = document.getElementById("listSelect"); listSelect.innerHTML = ""; // 清空旧选项 if (lists.length > 0) { lists.forEach((list) => { const option = document.createElement("option"); option.value = list.name; option.textContent = list.name; listSelect.appendChild(option); }); } else { const option = document.createElement("option"); option.value = ""; option.textContent = "没有找到可用的列表"; listSelect.appendChild(option); } } catch (error) { console.error("加载列表失败:", error); } }
模块三:在地图页面中执行获取列表的脚本
模拟用户点击「已保存」按钮,提取用户创建的收藏列表名称并返回。采用 setTimeout 等待列表加载完成。
function getUserLists() { return new Promise((resolve) => { const savedButton = document.querySelector( 'button[aria-label="已保存"], button[aria-label="Saved"]' ); if (savedButton) { savedButton.click(); setTimeout(() => { const listItems = document.querySelectorAll('div[role="listitem"]'); const lists = Array.from(listItems) .map((item) => { const nameElement = item.querySelector('div[role="heading"]'); return nameElement ? { name: nameElement.textContent.trim() } : null; }) .filter((item) => item !== null); resolve(lists); }, 2000); } else { resolve([]); } }); }
模块四:点击“开始标注”按钮后执行的逻辑
用户输入邮编并点击按钮后,向地图页面注入 markPostcodes 脚本,开始批量搜索和标注邮编位置。
document.getElementById("markButton").addEventListener("click", async () => { const postcodes = document.getElementById("postcodes").value; const status = document.getElementById("status"); if (!postcodes.trim()) { status.textContent = "请输入邮编"; return; } try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true, }); if (!tab.url.includes("google.com/maps")) { status.textContent = "请在谷歌地图页面使用此扩展"; return; } status.textContent = "正在处理..."; await chrome.scripting.executeScript({ target: { tabId: tab.id }, function: markPostcodes, args: [postcodes], }); status.textContent = "处理完成"; } catch (error) { status.textContent = "发生错误:" + error.message; } });
模块五:核心逻辑 - 标注邮编的函数 markPostcodes
5.1 解析用户输入的邮编格式
兼容中英文格式的邮编批量输入,支持区间和逗号分隔格式(支持 2000-2005、2000,2002 等)
function parsePostcodes(str) { const postcodes = []; const parts = str.replace(/[、,]/g, ",").split(","); for (const part of parts) { const trimmed = part.trim(); if (trimmed.includes("至")) { const [start, end] = trimmed.split("至").map((n) => parseInt(n.trim())); for (let i = start; i <= end; i++) { postcodes.push(i); } } else { const num = parseInt(trimmed); if (!isNaN(num)) { postcodes.push(num); } } } return postcodes; }
5.2 等待某个 DOM 元素出现的工具函数
循环检测页面中是否出现指定元素,常用于等待地图搜索结果或按钮加载。
function waitForElement(selector, timeout = 5000) { return new Promise((resolve, reject) => { const startTime = Date.now(); const checkElement = () => { const element = document.querySelector(selector); if (element) resolve(element); else if (Date.now() - startTime > timeout) reject(new Error(`等待元素 ${selector} 超时`)); else setTimeout(checkElement, 100); }; checkElement(); }); }
5.3 检查搜索是否有结果
执行搜索后判断是否真的有结果(包括判断有无“未找到结果”提示),确保不要误操作空结果。
async function checkSearchResults() { try { // 等待一段时间让结果加载 await new Promise((resolve) => setTimeout(resolve, 2000)); // 检查是否有搜索结果 const results = document.querySelectorAll('div[role="article"]'); console.log("搜索结果数量:", results.length); if (results.length > 0) { console.log("找到搜索结果"); return true; } // 检查是否有"未找到结果"的提示 const mainContent = document.querySelector('div[role="main"]'); if (mainContent) { const text = mainContent.textContent; console.log("主内容区域文本:", text); if (text.includes("未找到结果") || text.includes("No results found")) { console.log("明确提示未找到结果"); return false; } } // 如果没有明确的"未找到结果"提示,再等待一下看是否有结果出现 console.log("等待额外时间检查结果..."); await new Promise((resolve) => setTimeout(resolve, 2000)); const resultsAfterWait = document.querySelectorAll('div[role="article"]'); console.log("额外等待后的搜索结果数量:", resultsAfterWait.length); // 如果没有找到结果,但也没有明确的"未找到结果"提示,再检查一下是否有其他类型的结果 if (resultsAfterWait.length === 0) { const anyResults = document.querySelectorAll('div[role="button"]'); console.log("其他类型的结果数量:", anyResults.length); return anyResults.length > 0; } return resultsAfterWait.length > 0; } catch (error) { console.error("检查搜索结果失败:", error); return false; } }
5.4 检查某个邮编是否已经保存过
搜索该邮编,如果按钮显示「已保存」,或者已被添加到目标列表,则跳过该邮编的操作。
// 检查位置是否已经标注 async function isLocationMarked(postcode) { try { // 获取搜索框 const searchBox = await waitForElement('input[name="q"]'); // 输入搜索内容 searchBox.value = `邮政编码: ${postcode}, Australia`; searchBox.dispatchEvent(new Event("input", { bubbles: true })); // 点击搜索按钮 const searchButton = await waitForElement( 'button[aria-label="搜索"], button[aria-label="Search"]' ); searchButton.click(); // 等待搜索结果 await new Promise((resolve) => setTimeout(resolve, 2000)); // 检查是否有"已保存"按钮 const saveButton = document.querySelector( 'button[aria-label="已保存"], button[aria-label="Saved"]' ); // 如果按钮显示"已保存",说明已经保存过了 if (saveButton && saveButton.textContent.includes("已保存")) { console.log(`邮编 ${postcode} 已经标注过,跳过`); return true; } // 如果按钮显示"保存",则检查是否已经保存到目标列表 if (saveButton) { // 点击保存按钮查看是否已保存 saveButton.click(); await new Promise((resolve) => setTimeout(resolve, 2000)); // 检查是否已经保存到目标列表 const listItems = document.querySelectorAll( 'div[role="menuitemradio"]' ); for (const item of listItems) { const label = item.querySelector(".mLuXec")?.textContent; if (label && label.includes("🦘澳洲whv集签点")) { if (item.getAttribute("aria-checked") === "true") { // 如果已保存,关闭对话框 const doneButton = await waitForElement( 'button[aria-label="已保存"], button[aria-label="Done"]' ); doneButton.click(); return true; } break; } } // 如果未保存到目标列表,关闭对话框 const doneButton = await waitForElement( 'button[aria-label="已保存"], button[aria-label="Done"]' ); doneButton.click(); } return false; } catch (error) { console.error(`检查位置 ${postcode} 是否已标注时出错:`, error); return false; } }
5.5 执行搜索并添加到收藏列表
搜索该邮编 -> 点击第一个结果 -> 添加到指定的 Google Maps 列表中。
// 在地图上搜索并标记位置 async function searchAndMark(postcode) { try { // 先检查是否已经标注 const isMarked = await isLocationMarked(postcode); if (isMarked) { console.log(`邮编 ${postcode} 已经标注过,跳过`); return true; } // 检查是否找到结果 const hasResults = await checkSearchResults(); if (!hasResults) { console.log(`未找到邮编 ${postcode} 的结果`); return false; } // 点击第一个搜索结果 const firstResult = await waitForElement('div[role="article"]'); firstResult.click(); // 等待位置信息加载 await new Promise((resolve) => setTimeout(resolve, 1000)); // 保存到目标列表 await saveToTargetList(); return true; } catch (error) { console.error(`处理邮编 ${postcode} 时出错:`, error); return false; } }
5.6 保存到目标列表
// 保存到指定列表 async function saveToTargetList() { try { // 点击"保存"按钮 const saveButton = await waitForElement( 'button[aria-label="保存"], button[aria-label="Save"]' ); saveButton.click(); // 等待保存对话框加载 await new Promise((resolve) => setTimeout(resolve, 2000)); // 检查是否已经保存到目标列表 const checkboxes = document.querySelectorAll('div[role="checkbox"]'); let alreadySaved = false; let targetCheckbox = null; for (const checkbox of checkboxes) { const label = checkbox.getAttribute("aria-label"); if (label && label.includes("🦘澳洲whv集签点")) { targetCheckbox = checkbox; if (checkbox.getAttribute("aria-checked") === "true") { alreadySaved = true; } break; } } if (alreadySaved) { // 如果已经保存,直接点击"完成"按钮 const doneButton = await waitForElement( 'button[aria-label="已保存"], button[aria-label="Done"]' ); doneButton.click(); return true; } // 如果未保存,点击目标列表的复选框 if (targetCheckbox) { targetCheckbox.click(); } // 点击"完成"按钮 const doneButton = await waitForElement( 'button[aria-label="已保存"], button[aria-label="Done"]' ); doneButton.click(); return true; } catch (error) { console.error("保存到列表失败:", error); return false; } }
模块六:主函数
// 主函数 async function main() { const postcodes = parsePostcodes(postcodeStr); console.log(`找到 ${postcodes.length} 个邮编`); if (postcodes.length === 0) return; const notFoundPostcodes = []; // 处理所有邮编 for (const postcode of postcodes) { const success = await searchAndMark(postcode); if (!success) { notFoundPostcodes.push(postcode); } // 等待一下再处理下一个邮编 await new Promise((resolve) => setTimeout(resolve, 100)); } // 如果有未找到的邮编,显示在控制台 if (notFoundPostcodes.length > 0) { console.log("以下邮编未找到结果:", notFoundPostcodes.join(", ")); } } main();
Python 脚本版本 (main.py)
# 引入所需的库 import folium # 用于生成地图 from geopy.geocoders import Nominatim # 用于地址转经纬度(地理编码) from geopy.exc import GeocoderTimedOut # 用于处理地理编码超时异常 import webbrowser # 用于在默认浏览器中打开地图 import os # 用于文件路径处理 import time # 用于添加请求间的延迟 import re # 正则处理(备用) import ssl # 处理SSL证书问题(备用) import urllib3 # 控制HTTP警告 # 禁用由于未验证 SSL 证书引起的安全警告(避免控制台输出过多) urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) def parse_postcodes(postcode_str): """ 解析用户输入的邮编字符串,支持: - 使用中文顿号/逗号分隔多个邮编 - 使用‘至’表示的范围,如2832至2836 返回:邮编组成的整数列表 """ postcodes = [] # 替换中文顿号/逗号为英文逗号,再分割为列表 parts = postcode_str.replace('、', ',').replace(',', ',').split(',') for part in parts: part = part.strip() # 去除首尾空格 if '至' in part: # 如果是范围格式,拆分为起始和终止 start, end = part.split('至') start = int(start.strip()) end = int(end.strip()) # 添加范围内所有整数邮编 postcodes.extend(range(start, end + 1)) else: # 如果是单个邮编,尝试转为整数 try: postcodes.append(int(part)) except ValueError: continue # 如果无法转换则跳过(如非法字符) return postcodes def create_map_with_postcodes(postcode_str, max_retries=3): """ 根据邮编创建并标注地图 参数: - postcode_str:用户输入的邮编字符串 - max_retries:对某个邮编请求失败后重试次数 """ # 初始化 Nominatim 地理编码器 geolocator = Nominatim( user_agent="my_agent", # 设置访问代理名,防止被拒绝访问 timeout=10, # 设置最大超时秒数 scheme='https' # 使用 HTTPS 请求 ) # 解析输入,得到邮编列表 postcodes = parse_postcodes(postcode_str) print(f"共找到 {len(postcodes)} 个邮编") # 创建地图对象,默认中心为澳大利亚 m = folium.Map(location=[-25.2744, 133.7751], zoom_start=5) # 保存成功找到的位置 found_locations = [] # 遍历所有邮编 for postcode in postcodes: search_query = f"邮政编码:{postcode}, Australia" # 构造查询语句 for attempt in range(max_retries): # 最多重试 max_retries 次 try: # 地理编码:从邮编获取地理位置信息 location = geolocator.geocode(search_query) if location: # 如果成功找到位置,保存进结果列表 found_locations.append({ 'postcode': postcode, 'location': location }) print(f"已找到邮编 {postcode} 的位置") break # 跳出重试循环 except GeocoderTimedOut: # 超时处理:继续重试 if attempt < max_retries - 1: print(f"邮编 {postcode} 请求超时,正在进行第 {attempt + 2} 次尝试...") time.sleep(1) # 等待1秒再试 else: print(f"邮编 {postcode} 多次尝试后仍然无法连接到服务器") except Exception as e: # 处理其它异常 print(f"邮编 {postcode} 发生错误: {str(e)}") break # 如果有成功获取的位置,开始绘图 if found_locations: for loc in found_locations: # 在地图上添加标记 folium.Marker( [loc['location'].latitude, loc['location'].longitude], popup=f"邮编: {loc['postcode']}<br>地址: {loc['location'].address}", tooltip=f"邮编: {loc['postcode']}" ).add_to(m) # 保存地图为 HTML 文件 map_file = "map.html" m.save(map_file) # 在浏览器中打开地图 webbrowser.open('file://' + os.path.realpath(map_file)) print(f"\n成功在地图上标注了 {len(found_locations)} 个位置") else: print("没有找到任何有效的位置") def main(): """ 主程序入口 提示用户输入邮编字符串并开始处理 """ print("欢迎使用邮编地图标注工具") print("支持批量处理澳大利亚邮编") print("示例输入: 2356、2386、2387、2396、2405、2406、2672、2675、2825、2826、2829、2832至2836、2838至2840、2873、2878、2879、2898、2899") print("-" * 50) while True: postcode_str = input("\n请输入邮编(多个邮编用顿号、分隔,范围用至表示,输入 'q' 退出): ") if postcode_str.lower() == 'q': break # 用户退出 create_map_with_postcodes(postcode_str) # 如果是直接运行此脚本,则调用主程序 if __name__ == "__main__": main()
✅ 总结说明:
这段脚本实现的是:
- 解析用户输入的澳大利亚邮编(支持单个或范围);
- 利用 geopy 查询每个邮编的经纬度;
- 用 folium 在地图上标注这些邮编;
- 最终生成一个 HTML 地图并自动打开。
技术亮点
1.异步编程
- 使用 async/await 处理异步操作
- Promise 封装等待机制
- 优雅的错误处理
2.DOM 操作优化
- 使用 role 属性定位元素
- 支持多语言界面
- 智能等待元素加载
3.用户体验设计
- 批量处理能力
- 进度反馈
- 错误提示
4.代码健壮性
- 完善的错误处理
- 超时机制
- 日志记录
Table of Contents