diff --git a/account_cookie.json b/account_cookie.json index 32cf51a..4ef4a74 100644 --- a/account_cookie.json +++ b/account_cookie.json @@ -1,67 +1,67 @@ [ { "name": "tfstk", - "value": "gREthFDeE6fgrssCwhbhnyNsGkB3kw2ap5yWmjcMlWFLGSHDm-GmDSFYMf0f_cqY9SNxnn2bmxZxbk4MmffZD-NoD_flqgmaQ0ofZ_AYbUGtcxsmGw01D9Qpu_flqMb6dmCOZRq-YUhIeYijCfiXd9HoFAij5mMCpxMD1Et_GJ6KTxLXfngsAMMZHAijcSwCpxlIGmg_GJ6K3XGjZaCxOdGDDye4oACPRf-XckHt5w2sOnMUvA3tNRZ9cn9rBVh7CXCtMibER-uY0K-xuRazgYN61_0ahyFKBmIH50wLl7HUfgAtLP2QEXwJ3nyZXkELl2B1fJnt64qQtdLjXrNaDVz93TyIfWzraV_FTvEgqqhrJB1_KJhsyrFGTnGgyJNKrkAHqmPUlSMYOCIztuq--0RowxY1p9L2uVMETlR31IcEn7kKZOIvuEuFLvhlpT82uVM-pbX9eE8q895..", + "value": "gEzShTThBLv5ZgfRpL52Cwej6DuBVs7NRBGLsWLy943-d2NQ6DLFq4oIR-H4zYPryvgQO5nLLvPyZo2a6UzPq28Ir20dQOSV7bVo-2LiE4rIESFm9sl84JEEQ20dQO-2J0bs-8Nbr_kJDjHmOUh-JJKxDbkxw3n8pxnx6fJ-J2edGxhE_bd-wDHxHfDxp23LpSCjtxh-J2eLMsGngcAsOgMuN6_dnQMJ_XV-heLLkwmSC7KeJeUjN0aLwxN4grGSVAnCBw0Qy-PLzzmcq3u3glwQvRB24YEs1-iaVOT7C8cLHjePTHcbAWUir0AH-Rg7dcU-cBL_64HoljUOThD4lYyLPmCw5DuY7c380iXrY4G_pz2WOeeTg5zomy66HvqrsqhQ-1KIdDIrVFksS-KBGcYIGA5fGHxnzkyVpsV-4un-ibiVGs98x0hmGxCfGHvj2jc7Us1Xekf..", "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1786125734 + "expires": 1786155394 }, { "name": "arms_uid", - "value": "27b17ae2-81c6-4d4f-9496-3e0582ff16d2", + "value": "2b3df2db-a3c6-47ca-849c-a3cfa22bfe25", "domain": "cart.taobao.com", "path": "/", "secure": true, - "expires": 1786125734.323322 + "expires": 1786155394.629206 }, { "name": "ucn", "value": "center", "domain": "cart.taobao.com", "path": "/", - "expires": 1789673650.898824 + "expires": 1789673650.373558 }, { "name": "ubn", "value": "p", "domain": "cart.taobao.com", "path": "/", - "expires": 1805133733.898694 + "expires": 1805163393.37341 }, { "name": "havana_sdkSilent", - "value": "1770602527511", + "value": "1770632187990", "domain": ".taobao.com", "path": "/", "expires": -1 }, { "name": "havana_lgc_exp", - "value": "1801677727511", + "value": "1801707387990", "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1801706527.482485 + "expires": 1801736188.271188 }, { "name": "bxuab", "value": "0", "domain": ".taobao.com", "path": "/", - "expires": 1770574326.708635 + "expires": 1770603987.787739 }, { "name": "_m_h5_tk_enc", - "value": "4c9af36e47eb95f02be3d950f7d55fe8", + "value": "648302098481380669e8cb431a99c6f5", "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1770579126.261152 + "expires": 1770608786.32924 }, { "name": "sdkSilent", - "value": "1770602527511", + "value": "1770632187990", "domain": ".taobao.com", "path": "/", "secure": true, @@ -69,43 +69,28 @@ }, { "name": "_cc_", - "value": "Vq8l%2BKCLiw%3D%3D", + "value": "V32FPkk%2Fhw%3D%3D", "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1802138525.099279 + "expires": 1802168184.925658 }, { "name": "uc3", - "value": "nk2=2W5q6SxD\u0026id2=UU8OeHEFYuIE%2Fw%3D%3D\u0026lg2=VFC%2FuZ9ayeYq2g%3D%3D\u0026vt3=F8dD29DnFvY6X0CtCDI%3D", + "value": "nk2=2W5q6SxD\u0026lg2=U%2BGCWk%2F75gdr5Q%3D%3D\u0026vt3=F8dD29DnGZqiqJAND%2BU%3D\u0026id2=UU8OeHEFYuIE%2Fw%3D%3D", "domain": ".taobao.com", "path": "/", "secure": true, "httpOnly": true, - "expires": 1773165725.098812 - }, - { - "name": "thw", - "value": "cn", - "domain": ".taobao.com", - "path": "/", - "expires": 1802109726.659989 - }, - { - "name": "sn", - "value": "", - "domain": ".taobao.com", - "path": "/", - "secure": true, - "expires": 1773165725.098801 + "expires": 1773195384.92465 }, { "name": "isg", - "value": "BOfnyr79QnbK1cb6ZiUi-ICWdhuxbLtOBy1sRLlUA3adqAdqwTxLniW7yKg2QJPG", + "value": "BM3NGKTJmCW4DzwJPINXoxvZ3OlHqgF8mbM21g9SCWTTBu241_oRTBuFcppgxhk0", "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1786125729 + "expires": 1786155393 }, { "name": "cookie1", @@ -114,7 +99,7 @@ "path": "/", "secure": true, "httpOnly": true, - "expires": 1773165725.099435 + "expires": 1773195384.926001 }, { "name": "wk_unb", @@ -123,7 +108,7 @@ "path": "/", "secure": true, "httpOnly": true, - "expires": 1775786522.464568 + "expires": 1775816181.362139 }, { "name": "_l_g_", @@ -131,15 +116,15 @@ "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1773165725.099344 + "expires": 1773195384.925815 }, { "name": "cna", - "value": "h70PIpIjgAECAa+8pHQPSpRy", + "value": "RzEQIimlMgMBASQOA0EDvmjO", "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1805133703.735092 + "expires": 1805163337.88989 }, { "name": "tracknick", @@ -147,28 +132,11 @@ "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1802138525.099204 - }, - { - "name": "_samesite_flag_", - "value": "true", - "domain": ".taobao.com", - "path": "/", - "secure": true, - "httpOnly": true, - "expires": -1 - }, - { - "name": "_nk_", - "value": "%5Cu4F55%5Cu672A%5Cu5C39", - "domain": ".taobao.com", - "path": "/", - "secure": true, - "expires": 1773165725.099402 + "expires": 1802168184.925502 }, { "name": "uc1", - "value": "cookie14=UoYZaqFObbA9zg%3D%3D", + "value": "cookie14=UoYZaqLDqbCA2w%3D%3D", "domain": ".taobao.com", "path": "/", "secure": true, @@ -176,12 +144,12 @@ }, { "name": "skt", - "value": "3e7dedf3c5d7f59e", + "value": "20ea6b27c857e4b6", "domain": ".taobao.com", "path": "/", "secure": true, "httpOnly": true, - "expires": 1773165725.099071 + "expires": 1773195384.925233 }, { "name": "dnk", @@ -189,7 +157,7 @@ "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1773165725.099038 + "expires": 1773195384.925163 }, { "name": "cookie17", @@ -198,15 +166,15 @@ "path": "/", "secure": true, "httpOnly": true, - "expires": 1773165725.099007 + "expires": 1773195384.925061 }, { "name": "existShop", - "value": "MTc3MDU3MzcyNQ%3D%3D", + "value": "MTc3MDYwMzM4NA%3D%3D", "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1773165725.099129 + "expires": 1773195384.925355 }, { "name": "cancelledSubSites", @@ -214,24 +182,58 @@ "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1773165725.098946 + "expires": 1773195384.924932 }, { "name": "csg", - "value": "63a3b224", + "value": "0264eb7d", "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1773165725.098847 + "expires": 1773195384.924719 }, { - "name": "uc4", - "value": "id4=0%40U22Jqe1yyAMca0PbJ175RuXiEyQm\u0026nk4=0%4024ipPGyqAyNcYskAXekPLbg%3D", + "name": "wk_cookie2", + "value": "162fccd75f81c2ab3adf6c46893d896e", "domain": ".taobao.com", "path": "/", "secure": true, "httpOnly": true, - "expires": 1773165725.099159 + "expires": 1775816181.362046 + }, + { + "name": "lgc", + "value": "%5Cu4F55%5Cu672A%5Cu5C39", + "domain": ".taobao.com", + "path": "/", + "secure": true, + "expires": 1773195384.92485 + }, + { + "name": "havana_lgc2_0", + "value": "eyJoaWQiOjI3Njk3OTA4MjUsInNnIjoiZGY0MTk5Y2I5OGNmNDhkNWY2ZWEyMTc1OGUxNmNkMDIiLCJzaXRlIjowLCJ0b2tlbiI6IjEwUDI4UVlVcmQ0bmFLeDJxTW5jQnJnIn0", + "domain": ".taobao.com", + "path": "/", + "secure": true, + "httpOnly": true, + "expires": 1801736188.271261 + }, + { + "name": "_tb_token_", + "value": "b3b3916eee03", + "domain": ".taobao.com", + "path": "/", + "secure": true, + "expires": 1773195384.926065 + }, + { + "name": "uc4", + "value": "nk4=0%4024ipPGyqAyNcYskD8y6O6Zo%3D\u0026id4=0%40U22Jqe1yyAMca0PbJ175RQwJKeTy", + "domain": ".taobao.com", + "path": "/", + "secure": true, + "httpOnly": true, + "expires": 1773195384.925414 }, { "name": "unb", @@ -240,36 +242,26 @@ "path": "/", "secure": true, "httpOnly": true, - "expires": 1773165725.098682 + "expires": 1773195384.924399 }, { - "name": "mtop_partitioned_detect", - "value": "1", + "name": "sn", + "value": "", "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1770579126.261076 + "expires": 1773195384.924613 }, { - "name": "3PcFlag", - "value": "1770573702709", + "name": "thw", + "value": "cn", "domain": ".taobao.com", "path": "/", - "secure": true, - "expires": 1771466502.684566 - }, - { - "name": "sgcookie", - "value": "E100QYmZjz3WUI%2B6Yb1YO6UfaxPQ62sdAFFOvgkUiNEFnVLhaN%2FpQ9g6DjgxTOJNI9udV24oFQAPLP17peJOZks%2FOvhoACbilBAC1Hhe3vMAoa9jvUDa9%2F5D%2FVIoj40y%2Ffnx", - "domain": ".taobao.com", - "path": "/", - "secure": true, - "httpOnly": true, - "expires": 1802138522.464589 + "expires": 1802139363.365101 }, { "name": "ultraCookieBase", - "value": "1k6S5%2BcxkgQpZA4Xfbyi8gTG%2By4cSksG3aXzy18EbaWBcFwmxQrEFaptGmtlgMhMyFkb9xXZw49oDDyhu36m6TasHWvnb5c0fdjGmLf0P4VfHzktxDxzIUgpJasHyc2AFlKN6wB8MGHOhvDSnHXMMA9Ak3KjaEUqLHbfZjokqSviRjqfKadUT%2BRD5bo15bmtMvqZRIj851tLvlG8tRKCZ5OwrvUSwimB4MvXB0EjsKp0nOQx3IDBO%2Bf2xICypyv1o13sbZyPDPfe756xwOhelodlzHk7hp%2FhysThoa8BCrcd65zMYzb5Tdio2bhkgVfFIya0JcQ%3D%3D", + "value": "1k6S5%2BcxkgQpZBn6L87UFT1gB9dmSToMf4PxhORghtU%2FDagzMKO981v6AnsKpz%2BwDWaU87CeU7Sgb2N%2FPec%2FgpkEJ5kKklDVUZM3AmRyZ1l7uyhr9OreEmQsDaZ3f2MDNFZ8eblrhJsnqpN1oxZ4DYYGQsy6myUzIIK14RgAkbxEeNL%2BA4Izs76EUOpLCEPn%2FwSpHEyB6tiyfgJkngMhCgcOkn2lpq3PLd%2BtyrvoRQUy1oDnrjBCq%2FyEEMn5Ldi6IAMTiF5vBvSzQwsoeX88gnDa%2BTSrhB3UWCPdK4UD1xZU2fe4a7k5OzHib5zf9wlR7uJsP", "domain": ".taobao.com", "path": "/", "secure": true, @@ -282,41 +274,32 @@ "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1801706527.482438 + "expires": 1801736188.27097 }, { - "name": "wk_cookie2", - "value": "182060f48332dfef01c11cf24af15454", + "name": "mtop_partitioned_detect", + "value": "1", + "domain": ".taobao.com", + "path": "/", + "secure": true, + "expires": 1770608786.329052 + }, + { + "name": "3PcFlag", + "value": "1770603366030", + "domain": ".taobao.com", + "path": "/", + "secure": true, + "expires": 1771496166.315193 + }, + { + "name": "sgcookie", + "value": "E100DaLni74mW2GGJkv7IBQE9jRlQNcxZe5nEjs4GYaV77r6LAZVU3a4Ee0FYbKjNVMzsBquzyxjwu27MnV%2B6WIq%2BU4M%2FvkKb%2FnDq2%2Bh4oModQpRJ1N8pm4BjLT%2BEPKB4NKs", "domain": ".taobao.com", "path": "/", "secure": true, "httpOnly": true, - "expires": 1775786522.464268 - }, - { - "name": "lgc", - "value": "%5Cu4F55%5Cu672A%5Cu5C39", - "domain": ".taobao.com", - "path": "/", - "secure": true, - "expires": 1773165725.098914 - }, - { - "name": "havana_lgc2_0", - "value": "eyJoaWQiOjI3Njk3OTA4MjUsInNnIjoiNmIwZGE3OTVhOTNmNDE0MTQ5ZWQ2YmYzNjY0ZDAyM2QiLCJzaXRlIjowLCJ0b2tlbiI6IjFYNXFrLTlIeExkV2k5MUtnTXlVV0hnIn0", - "domain": ".taobao.com", - "path": "/", - "secure": true, - "httpOnly": true, - "expires": 1801706527.482518 - }, - { - "name": "_tb_token_", - "value": "e71eb736fe5ee", - "domain": ".taobao.com", - "path": "/", - "secure": true, - "expires": 1773165725.099468 + "expires": 1802168181.362182 }, { "name": "sg", @@ -324,7 +307,24 @@ "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1773165725.099374 + "expires": 1773195384.925878 + }, + { + "name": "_nk_", + "value": "%5Cu4F55%5Cu672A%5Cu5C39", + "domain": ".taobao.com", + "path": "/", + "secure": true, + "expires": 1773195384.925937 + }, + { + "name": "_samesite_flag_", + "value": "true", + "domain": ".taobao.com", + "path": "/", + "secure": true, + "httpOnly": true, + "expires": -1 }, { "name": "xlly_s", @@ -332,31 +332,31 @@ "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1770832903 + "expires": 1770862538 }, { "name": "t", - "value": "b19353ffe27e48a62571c666710e67aa", + "value": "e4f280cfb52f5d89ece3d20bfa6b2567", "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1773165725.098976 + "expires": 1773195384.924994 }, { "name": "_m_h5_tk", - "value": "bbf86786a9fc68cbff2d3ea69228ef04_1770584166279", + "value": "26d4b2054abadb735d63ee433ff5dd9a_1770611666043", "domain": ".taobao.com", "path": "/", "secure": true, - "expires": 1770579126.261124 + "expires": 1770608786.329178 }, { "name": "cookie2", - "value": "1a0986ed49f3d18139c1d71113f6ce18", + "value": "1d18a139e7c5fd502d3420930fddeed2", "domain": ".taobao.com", "path": "/", "secure": true, "httpOnly": true, - "expires": 1773165725.0991 + "expires": 1773195384.925293 } ] \ No newline at end of file diff --git a/cmd/main.go b/cmd/main.go index 59988dc..7b6ef1a 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,201 +1,156 @@ package main import ( - "encoding/json" "flag" "fmt" - "go-sccchina/libs" "log" "os" - "strings" "time" - "github.com/go-rod/rod" - "github.com/go-rod/rod/lib/proto" + "go-taobao/config" + "go-taobao/libs" + "go-taobao/services" + "go-taobao/utils" ) -var login = flag.Bool("login", false, "是否登录") -var test = flag.Bool("time", false, "测试") -var Browser libs.Browser +var ( + mode = flag.String("mode", "", "运行模式: login(登录) | rush(抢购)") +) -// var Chrome libs.Chrome func main() { flag.Parse() - log.Println(*login) - if *login { - loginFun() - } - if *test { - obj := Browser.GetBrowser(1, &libs.Fingerprint{}) - browser := obj.Browser - page := obj.Page - cookies, _ := loadCookiesFromFile("account_cookie.json") - browser.SetCookies(cookies) - page.MustNavigate("https://cart.taobao.com/cart.htm?spm=tbpc.mytb_index.leftnav.1.6db2782d0TVVip").MustWaitLoad() - page.MustWaitNavigation() - page.MustWaitLoad() // DOM 加载完成 - page.MustWaitIdle() // 基本静止(减少早期误判) - page.MustActivate() // 前置以便扫码 - // // 获取当前课程列表 - // res := getApiResponse(page, "https://hnit.sccchina.net/student/student/coursestudy/getlist") - // var dataBody map[string]any - // json.Unmarshal([]byte(res), &dataBody) - // list := dataBody["items"].([]interface{}) - // // pretty, _ := json.MarshalIndent(list, "", " ") - // // 判断当前有多少个课程需要学习 - // for _, v := range list { - // m := v.(map[string]interface{}) - // realCoursewarePlayNum := m["realCoursewarePlayNum"].(string) - // realCoursewarePlayTime := m["realCoursewarePlayTime"].(string) - // var showScore string = "0" - // if m["showScore"] != nil { - // if str, ok := m["showScore"].(string); ok { - // showScore = str - // } - // } - // courseVersionID := m["courseVersionID"].(string) - // teachplanCourseVersionId := m["teachplanCourseVersionId"].(string) - // sign := m["sign"].(string) - // userAccountId := m["userAccountId"].(string) - // versionName := m["versionName"].(string) - // // 分割字符串 判断当前 课时 成绩 - // playTimeArr := strings.Split(realCoursewarePlayTime, " / ") - // // 将第一个转为数字 - // activeTime, _ := strconv.ParseFloat(strings.TrimSpace(playTimeArr[0]), 64) - // var endTime float64 - // if strings.TrimSpace(playTimeArr[1]) == "--" { - // endTime = 360 - // } else { - // TimeEnd, _ := strconv.ParseFloat(strings.TrimSpace(playTimeArr[1]), 64) - // endTime = TimeEnd - // } - // // 判断有哪些课程需要观看 - // // var pages []*rod.Page - // if showScore == "0" && activeTime < endTime { - // log.Println(activeTime) - // log.Println(endTime) - // log.Println(realCoursewarePlayNum, realCoursewarePlayTime, showScore, courseVersionID, teachplanCourseVersionId, sign, userAccountId, versionName) - // url := fmt.Sprintf(`https://hnit.sccchina.net/student/videolearning.html#Subpage/StudentVersionVideo?courseVersionId=%s&teachplanCourseVersionId=%s&sign=%s&userAccountId=%s&syncPracticePiwik=1&piwikSiteId=750&versionName=%s`, courseVersionID, teachplanCourseVersionId, sign, userAccountId, versionName) - // log.Println(url) - // time.Sleep(3 * time.Second) - // p := browser.MustPage(url) - // satrt := p.Timeout(5 * time.Second).MustElement(`#button-courseware`) - // satrt.MustClick() - // time.Sleep(2 * time.Second) - // log.Println(1) - // iframeEl := p.Timeout(10*time.Second).MustElementR(`iframe`, ".*") - // log.Println(2) - // log.Println(iframeEl) - // if iframeEl != nil { - // iframePage := iframeEl.MustFrame() - // iframePage.MustWaitLoad() - // // 再次进入内嵌的iframe - // iframeEl2 := iframePage.Timeout(10 * time.Second).MustElement(`iframe#iframe`) - // iframePage2 := iframeEl2.MustFrame() - // iframePage2.MustWaitLoad() - // cl := iframePage2.Timeout(5 * time.Second).MustElement(`em.pop_close`) - // cl.MustClick() - // } - // time.Sleep(3 * time.Second) - // // pages = append(pages, p) - // // go func() { - // // browser.MustPage(url) - // // }() - // } - // } - // log.Println(string(pretty)) - select {} - } + cfg := config.DefaultConfig() + switch *mode { + case "login": + runLogin(cfg) + case "rush": + runRush(cfg) + default: + fmt.Println("淘宝抢购脚本") + fmt.Println("用法:") + fmt.Println(" -mode login 扫码登录并保存 Cookie") + fmt.Println(" -mode rush 启动定时抢购") + fmt.Printf("\n当前抢购目标时间: %s\n", cfg.TargetTime.Format("2006-01-02 15:04:05")) + os.Exit(1) + } } -func loginFun() { - obj := Browser.GetBrowser(1, &libs.Fingerprint{}) - browser := obj.Browser - page := obj.Page - page.MustNavigate(`https://login.taobao.com/havanaone/login/login.htm`).MustWaitLoad() - page.MustWaitNavigation() - page.MustWaitLoad() // DOM 加载完成 - page.MustWaitIdle() // 基本静止(减少早期误判) - page.MustActivate() // 前置以便扫码 - - var usl string = "https://login.taobao.com/havanaone/login/login.htm" - for usl == "https://login.taobao.com/havanaone/login/login.htm" { - uri := page.MustEval("() => window.location.href").String() - log.Println("获取当前网页URL:", uri) - usl = uri - time.Sleep(5 * time.Second) +// runLogin 登录模式:扫码登录并保存 Cookie +func runLogin(cfg *config.RushConfig) { + log.Println("========== 登录模式 ==========") + auth := services.NewAuth(cfg) + if err := auth.Login(); err != nil { + log.Fatalf("[错误] 登录失败: %v", err) } - page.MustNavigate("https://cart.taobao.com/cart.htm?spm=tbpc.mytb_index.leftnav.1.6db2782d0TVVip").MustWaitLoad() - page.MustWaitNavigation() - page.MustWaitLoad() // DOM 加载完成 - page.MustWaitIdle() // 基本静止(减少早期误判) - page.MustActivate() // 前置以便扫码 - cookieArr := getPageCookie(page, "https://cart.taobao.com/cart.htm?spm=tbpc.mytb_index.leftnav.1.6db2782d0TVVip") - saveCookiesToFile(cookieArr, "account_cookie.json") - browser.Close() + log.Println("[完成] 登录成功,Cookie 已保存") } -func getPageCookie(page *rod.Page, url string) []*proto.NetworkCookieParam { - cookieArr, _ := page.Cookies([]string{url}) - var cookies []*proto.NetworkCookieParam - for _, cookie := range cookieArr { - cookies = append(cookies, &proto.NetworkCookieParam{ - Name: cookie.Name, - Value: cookie.Value, - Domain: cookie.Domain, - Path: cookie.Path, - Expires: cookie.Expires, // 过期时间 - HTTPOnly: cookie.HTTPOnly, // 禁止 JS 访问 - Secure: cookie.Secure, // 仅 HTTPS 传输 - }) - } - return cookies -} +// runRush 抢购模式:定时抢购主流程 +// 流程: 提前进购物车 → 全选 → 结算 → 进入订单确认页 → 到点刷新 → 检测可购买 → 提交 +func runRush(cfg *config.RushConfig) { + log.Println("========== 抢购模式 ==========") + log.Printf("[配置] 目标时间: %s", cfg.TargetTime.Format("2006-01-02 15:04:05")) + log.Printf("[配置] 最大重试: %d 次", cfg.MaxRetry) + log.Printf("[配置] 提前量: %v", cfg.SubmitAdvance) -func saveCookiesToFile(cookies []*proto.NetworkCookieParam, filename string) error { - data, err := json.MarshalIndent(cookies, "", " ") + // 1. 同步服务器时间 + log.Println("\n--- 第1步: 同步服务器时间 ---") + timeSync, err := utils.NewTimeSync(5) if err != nil { - return err + log.Fatalf("[错误] 时间同步失败: %v", err) } - return os.WriteFile(filename, data, 0644) -} + log.Printf("[时间] 本地时间: %s", time.Now().Format("15:04:05.000")) + log.Printf("[时间] 服务器时间: %s", timeSync.Now().Format("15:04:05.000")) + log.Printf("[时间] 偏移量: %v", timeSync.Offset) -func loadCookiesFromFile(filename string) ([]*proto.NetworkCookieParam, error) { - data, err := os.ReadFile(filename) + remaining := timeSync.UntilTarget(cfg.TargetTime) + if remaining <= 0 { + log.Println("[警告] 抢购时间已过!") + return + } + log.Printf("[时间] 距离抢购还有: %v", remaining.Round(time.Second)) + + // 2. 加载 Cookie & 启动浏览器 + log.Println("\n--- 第2步: 启动浏览器 ---") + auth := services.NewAuth(cfg) + cookies, err := auth.LoadCookies() if err != nil { - return nil, err + log.Fatalf("[错误] 加载 Cookie 失败: %v (请先运行 -mode login)", err) } - var cookies []*proto.NetworkCookieParam - if err := json.Unmarshal(data, &cookies); err != nil { - return nil, err - } - return cookies, nil -} -// 获取指定接口的响应参数 -func getApiResponse(page *rod.Page, url string) string { - // 定义一个通道接收消息 - ch := make(chan string) - go page.EachEvent(func(e *proto.PageLoadEventFired) { - log.Println("1234567890") - }, func(e *proto.NetworkResponseReceived) { - if strings.Contains(e.Response.URL, url) { - reply, err := (proto.NetworkGetResponseBody{RequestID: e.RequestID}).Call(page) - if err != nil { - fmt.Println(err) + var browserMgr libs.Browser + browserInfo := browserMgr.GetBrowser(cfg.AccountID, &libs.Fingerprint{}) + if err := auth.InjectCookies(browserInfo.Browser, cookies); err != nil { + log.Fatalf("[错误] 注入 Cookie 失败: %v", err) + } + log.Println("[浏览器] Cookie 注入成功") + + // 3. 提前打开购物车,全选商品,点击结算进入订单页 + log.Println("\n--- 第3步: 进入购物车并结算 ---") + cart := services.NewCart(browserInfo.Page) + cart.Open() + cart.DisableAnimations() + cart.SelectAll() + + log.Println("[购物车] 点击结算,进入订单确认页...") + if err := cart.ClickCheckout(); err != nil { + log.Fatalf("[错误] 结算失败: %v", err) + } + + // 4. 等待进入订单确认页 + order := services.NewOrder(browserInfo.Page) + if err := order.WaitForConfirmPage(15 * time.Second); err != nil { + log.Fatalf("[错误] 进入订单确认页失败: %v", err) + } + log.Println("[订单] 已停留在订单确认页,等待抢购时间...") + + // 5. 创建定时器 & 启动倒计时 + timer := services.NewTimer(cfg, timeSync) + countdownDone := make(chan struct{}) + go timer.PrintCountdown(countdownDone) + + // 6. 精确等待到抢购时间(目标时间 - 提前量) + log.Println("\n--- 第4步: 等待抢购时间 ---") + timer.WaitUntilRush() + close(countdownDone) + + // 7. 到点! 刷新订单页 → 检测是否可提交 → 提交 + log.Println("\n--- 第5步: 开始抢购! ---") + + for attempt := 1; attempt <= cfg.MaxRetry; attempt++ { + log.Printf("[抢购] 第 %d/%d 次尝试", attempt, cfg.MaxRetry) + + // 刷新订单确认页 + order.Refresh() + + // 检测是否可以提交 + if !order.CanSubmit() { + log.Println("[抢购] 当前仍不可购买") + if attempt < cfg.MaxRetry { + time.Sleep(cfg.RetryInterval) } - ch <- reply.Body - // fmt.Println(reply.Body) + continue } - })() - select { - case res := <-ch: - return res - case <-time.After(30 * time.Second): - log.Println("等待上传响应超时") - return "" + // 可以购买,立即提交! + result := order.Submit() + if result.Success { + log.Println("🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉") + log.Printf("[成功] %s", result.Message) + log.Println("🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉🎉") + log.Println("[提示] 浏览器保持打开,请尽快完成付款!") + select {} + } + + log.Printf("[抢购] 提交失败: %s", result.Message) + if attempt < cfg.MaxRetry { + time.Sleep(cfg.RetryInterval) + } } + + log.Println("[结束] 已达最大重试次数") + log.Println("[提示] 浏览器保持打开,你可以手动操作") + select {} } diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..758c02a --- /dev/null +++ b/config/config.go @@ -0,0 +1,84 @@ +package config + +import "time" + +// RushConfig 抢购配置 +type RushConfig struct { + // 抢购目标时间 + TargetTime time.Time + // 提交失败最大重试次数 + MaxRetry int + // 重试间隔 + RetryInterval time.Duration + // 提前多久预加载购物车页面 + PreloadBefore time.Duration + // 提前多少毫秒开始提交(网络延迟补偿) + SubmitAdvance time.Duration + // Cookie 文件路径 + CookieFile string + // 浏览器用户数据目录 ID + AccountID int +} + +// DefaultConfig 返回默认抢购配置 +func DefaultConfig() *RushConfig { + // 默认目标: 2026-02-10 20:00:00 CST + loc, _ := time.LoadLocation("Asia/Shanghai") + target := time.Date(2026, 2, 10, 20, 0, 0, 0, loc) + + return &RushConfig{ + TargetTime: target, + MaxRetry: 5, + RetryInterval: 200 * time.Millisecond, + PreloadBefore: 30 * time.Second, + SubmitAdvance: 500 * time.Millisecond, + CookieFile: "account_cookie.json", + AccountID: 1, + } +} + +// 淘宝关键页面 URL +const ( + LoginURL = "https://login.taobao.com/havanaone/login/login.htm" + CartURL = "https://cart.taobao.com/cart.htm" +) + +// TimeAPIs 时间同步 API 列表(按优先级排列,依次降级) +var TimeAPIs = []TimeAPIConfig{ + { + Name: "淘宝 H5", + URL: "https://h5api.m.taobao.com/h5/mtop.common.getTimestamp/", + Type: TimeAPITypeTaobaoH5, + }, + { + Name: "苏宁", + URL: "https://quan.suning.com/getSysTime.do", + Type: TimeAPITypeSuning, + }, + { + Name: "淘宝主站", + URL: "https://www.taobao.com", + Type: TimeAPITypeHTTPHeader, + }, + { + Name: "天猫", + URL: "https://www.tmall.com", + Type: TimeAPITypeHTTPHeader, + }, +} + +// TimeAPIType 时间 API 类型 +type TimeAPIType int + +const ( + TimeAPITypeTaobaoH5 TimeAPIType = iota // 淘宝 H5 JSON 响应 + TimeAPITypeSuning // 苏宁 JSON 响应 + TimeAPITypeHTTPHeader // 使用 HTTP Date 响应头 +) + +// TimeAPIConfig 时间 API 配置 +type TimeAPIConfig struct { + Name string + URL string + Type TimeAPIType +} diff --git a/go.mod b/go.mod index df23884..391a1aa 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module go-sccchina +module go-taobao go 1.25.1 diff --git a/libs/browser.go b/libs/browser.go index bb93daf..fa72a7f 100644 --- a/libs/browser.go +++ b/libs/browser.go @@ -1,13 +1,16 @@ package libs import ( + "log" "sync" "github.com/go-rod/rod" ) +// Browser 浏览器实例管理器 type Browser struct{} +// BrowserInfo 浏览器实例信息 type BrowserInfo struct { Id int Browser *rod.Browser @@ -15,7 +18,7 @@ type BrowserInfo struct { Page *rod.Page } -// 浏览器指纹数据 +// Fingerprint 浏览器指纹配置 type Fingerprint struct { Viewport []struct { Height int @@ -26,58 +29,61 @@ type Fingerprint struct { } var ( - BrowserPool = make(map[int]*BrowserInfo) // 初始化map - poolMutex sync.RWMutex // 添加读写锁保证并发安全 + browserPool = make(map[int]*BrowserInfo) + poolMutex sync.RWMutex ) -var ChromeExample Chrome - +// GetBrowser 获取或创建浏览器实例(线程安全,双重检查锁定) func (*Browser) GetBrowser(id int, params *Fingerprint) *BrowserInfo { poolMutex.RLock() - value, ok := BrowserPool[id] + value, ok := browserPool[id] poolMutex.RUnlock() if ok { return value } - // 未找到时获取写锁(互斥) poolMutex.Lock() defer poolMutex.Unlock() - // 双检查:避免在等待锁期间其他goroutine已创建 - if value, ok := BrowserPool[id]; ok { + // 双重检查 + if value, ok := browserPool[id]; ok { return value } - // 创建新实例(补充CancelFunc) - var ChromeExample Chrome - browser, url, page := ChromeExample.Create(id, params) + var chrome Chrome + browser, url, page := chrome.Create(id, params) browserInfo := &BrowserInfo{ Id: id, Browser: browser, Url: url, Page: page, } - BrowserPool[id] = browserInfo + browserPool[id] = browserInfo + log.Printf("[浏览器] 创建实例 #%d", id) return browserInfo } -func (*Browser) GetAllBrowser() map[int]*BrowserInfo { - return BrowserPool -} - -func (*Browser) CancelAllBrowser() { - for _, v := range BrowserPool { +// CloseAll 关闭所有浏览器实例 +func (*Browser) CloseAll() { + poolMutex.Lock() + defer poolMutex.Unlock() + for id, v := range browserPool { v.Browser.MustClose() + delete(browserPool, id) } + log.Println("[浏览器] 已关闭所有实例") } -func (*Browser) CancelBrowser(id int) { - browserInfo := BrowserPool[id] - if browserInfo == nil { +// Close 关闭指定浏览器实例 +func (*Browser) Close(id int) { + poolMutex.Lock() + defer poolMutex.Unlock() + info, ok := browserPool[id] + if !ok { return } - browserInfo.Browser.MustClose() - delete(BrowserPool, id) + info.Browser.MustClose() + delete(browserPool, id) + log.Printf("[浏览器] 已关闭实例 #%d", id) } diff --git a/libs/chrome.go b/libs/chrome.go index 12c0a56..418c2a8 100644 --- a/libs/chrome.go +++ b/libs/chrome.go @@ -11,70 +11,60 @@ import ( "github.com/go-rod/stealth" ) +// Chrome 浏览器创建器 type Chrome struct{} +// Create 创建一个带反检测的 Chrome 浏览器实例 func (c *Chrome) Create(accountId int, params *Fingerprint) (*rod.Browser, string, *rod.Page) { - var accountIdStr = strconv.Itoa(accountId) - userDataDir := "user-data/" + accountIdStr + userDataDir := "user-data/" + strconv.Itoa(accountId) absPath, _ := filepath.Abs(userDataDir) - log.Printf("用户数据目录: %s", absPath) - path, _ := launcher.LookPath() // 获取当前系统的浏览器目录 - // 判断是否传递代理 - // var proxyHandle utils.ProxyHandle - // protocol, username, password, ip, port, errProxy := proxyHandle.ParseProxy(params.Proxy) + log.Printf("[Chrome] 用户数据目录: %s", absPath) + + chromePath, _ := launcher.LookPath() l := launcher.New(). - Bin(path). - // Proxy("210.51.27.121:50003"). // 直接传入完整认证URL - Delete("use-mock-keychain"). // delete flag "--use-mock-keychain" - Set("disable-blink-features", "AutomationControlled"). // 绕过自动化检测 - // Set("incognito"). // 无痕模式 - Set("user-data-dir", absPath). // 数据持久化目录 - Set("window-size", "1920,1480"). // 窗口尺寸 - Set("disable-infobars", "true"). // 隐藏自动化提示栏 - Set("no-sandbox", "true"). + Bin(chromePath). + Delete("use-mock-keychain"). + // 反自动化检测 + Set("disable-blink-features", "AutomationControlled"). Set("excludeSwitches", "enable-automation"). - Set("enable-gpu"). // 启用 GPU 加速 - Set("ignore-certificate-errors"). // 忽略证书错误 - Set("use-fake-ui-for-media-stream"). // 允许媒体流 - Set("autoplay-policy", "no-user-gesture-required"). // 自动播放 + Set("disable-infobars", "true"). + // 数据持久化 & 窗口 + Set("user-data-dir", absPath). + Set("window-size", "1920,1480"). + // 性能 & 安全 + Set("no-sandbox", "true"). + Set("enable-gpu"). Set("ignore-certificate-errors"). Set("disable-application-cache"). Set("disable-dev-shm-usage"). - // 禁用webrtc - Set("disable-webrtc", "true"). // 核心禁用参数 - Set("disable-features", "WebRtcHideLocalIpsWithMdns"). // 隐藏本地IP - Set("webrtc-ip-handling-policy", "disable_non_proxied_udp"). // 禁用非代理UDP + // 媒体 + Set("use-fake-ui-for-media-stream"). + Set("autoplay-policy", "no-user-gesture-required"). + // 隐私: 禁用 WebRTC 防止 IP 泄露 + Set("disable-webrtc", "true"). + Set("disable-features", "WebRtcHideLocalIpsWithMdns"). + Set("webrtc-ip-handling-policy", "disable_non_proxied_udp"). Set("force-webrtc-ip-handling-policy"). - Headless(false) // 关闭无头模式 - - // 修改时区 - l.Set("disable-geolocation", "true") // 启用地理定位API - l.Set("disable-web-security", "true") // 允许跨域(某些网站需要) 可以跨域访问iframe标签 - l.Set("disable-notifications", "true") - l.Set("disable-site-isolation-trials", "true") - // if errProxy == nil { - // if strings.Contains(protocol, "http") { - // log.Println(protocol + "://" + ip + ":" + port) - // l = l.Proxy(ip + ":" + port) - // } - // } + // 跨域 & 通知 + Set("disable-web-security", "true"). + Set("disable-notifications", "true"). + Set("disable-site-isolation-trials", "true"). + Set("disable-geolocation", "true"). + Headless(false) uri := l.MustLaunch() browser := rod.New(). ControlURL(uri). - MustConnect().NoDefaultDevice() + MustConnect(). + NoDefaultDevice() browser.MustIgnoreCertErrors(true) - // if errProxy == nil { - // if strings.Contains(protocol, "http") { - // log.Println(username, password) - // go browser.MustHandleAuth(username, password)() - // } - // } - page := stealth.MustPage(browser) - // page := browser.MustPage() + // 使用 stealth 模式创建页面 + page := stealth.MustPage(browser) + + // 为所有新打开的页面也注入 stealth 脚本 go browser.EachEvent(func(e *proto.TargetTargetCreated) { if e.TargetInfo.Type != proto.TargetTargetInfoTypePage { return @@ -82,8 +72,8 @@ func (c *Chrome) Create(accountId int, params *Fingerprint) (*rod.Browser, strin browser.MustPageFromTargetID(e.TargetInfo.TargetID).MustEvalOnNewDocument(stealth.JS) })() - // 在页面加载前注入时区覆盖代码 page.MustSetViewport(1920, 1480, 1.0, false) + log.Printf("[Chrome] 浏览器已启动 (账号 #%d)", accountId) return browser, uri, page } diff --git a/services/auth.go b/services/auth.go new file mode 100644 index 0000000..c578f9f --- /dev/null +++ b/services/auth.go @@ -0,0 +1,113 @@ +package services + +import ( + "encoding/json" + "log" + "os" + "time" + + "go-taobao/config" + "go-taobao/libs" + + "github.com/go-rod/rod" + "github.com/go-rod/rod/lib/proto" +) + +// Auth 认证服务,管理登录和 Cookie +type Auth struct { + browser *libs.BrowserInfo + cfg *config.RushConfig +} + +// NewAuth 创建认证服务 +func NewAuth(cfg *config.RushConfig) *Auth { + return &Auth{cfg: cfg} +} + +// Login 打开登录页面,等待用户扫码,保存 Cookie +func (a *Auth) Login() error { + var mgr libs.Browser + a.browser = mgr.GetBrowser(a.cfg.AccountID, &libs.Fingerprint{}) + + page := a.browser.Page + page.MustNavigate(config.LoginURL).MustWaitLoad() + page.MustWaitNavigation() + page.MustWaitLoad() + page.MustWaitIdle() + page.MustActivate() + + log.Println("[登录] 请扫码登录...") + + // 轮询等待登录成功(URL 变化表示登录完成) + for { + uri := page.MustEval("() => window.location.href").String() + log.Printf("[登录] 当前 URL: %s", uri) + if uri != config.LoginURL { + log.Println("[登录] 登录成功!") + break + } + time.Sleep(3 * time.Second) + } + + // 导航到购物车页面获取完整 Cookie + page.MustNavigate(config.CartURL).MustWaitLoad() + page.MustWaitNavigation() + page.MustWaitLoad() + page.MustWaitIdle() + + // 提取并保存 Cookie + cookies := a.extractCookies(page, config.CartURL) + if err := a.SaveCookies(cookies); err != nil { + return err + } + + log.Printf("[登录] Cookie 已保存到 %s", a.cfg.CookieFile) + a.browser.Browser.MustClose() + return nil +} + +// LoadCookies 从文件加载 Cookie +func (a *Auth) LoadCookies() ([]*proto.NetworkCookieParam, error) { + data, err := os.ReadFile(a.cfg.CookieFile) + if err != nil { + return nil, err + } + var cookies []*proto.NetworkCookieParam + if err := json.Unmarshal(data, &cookies); err != nil { + return nil, err + } + log.Printf("[认证] 已加载 %d 条 Cookie", len(cookies)) + return cookies, nil +} + +// SaveCookies 保存 Cookie 到文件 +func (a *Auth) SaveCookies(cookies []*proto.NetworkCookieParam) error { + data, err := json.MarshalIndent(cookies, "", " ") + if err != nil { + return err + } + return os.WriteFile(a.cfg.CookieFile, data, 0644) +} + +// InjectCookies 将 Cookie 注入浏览器 +func (a *Auth) InjectCookies(browser *rod.Browser, cookies []*proto.NetworkCookieParam) error { + return browser.SetCookies(cookies) +} + +// extractCookies 从页面提取 Cookie +func (a *Auth) extractCookies(page *rod.Page, url string) []*proto.NetworkCookieParam { + cookieArr, _ := page.Cookies([]string{url}) + var cookies []*proto.NetworkCookieParam + for _, cookie := range cookieArr { + cookies = append(cookies, &proto.NetworkCookieParam{ + Name: cookie.Name, + Value: cookie.Value, + Domain: cookie.Domain, + Path: cookie.Path, + Expires: cookie.Expires, + HTTPOnly: cookie.HTTPOnly, + Secure: cookie.Secure, + }) + } + return cookies +} diff --git a/services/cart.go b/services/cart.go new file mode 100644 index 0000000..db315b3 --- /dev/null +++ b/services/cart.go @@ -0,0 +1,132 @@ +package services + +import ( + "fmt" + "log" + "time" + + "github.com/go-rod/rod" +) + +// Cart 购物车操作服务 +type Cart struct { + page *rod.Page +} + +// NewCart 创建购物车服务 +func NewCart(page *rod.Page) *Cart { + return &Cart{page: page} +} + +// Open 打开购物车页面并等待完全加载 +func (c *Cart) Open() error { + log.Println("[购物车] 正在打开购物车页面...") + c.page.MustNavigate("https://cart.taobao.com/cart.htm").MustWaitLoad() + c.page.MustWaitIdle() + log.Println("[购物车] 页面加载完成") + return nil +} + +// Refresh 刷新购物车页面 +func (c *Cart) Refresh() error { + log.Println("[购物车] 刷新购物车...") + c.page.MustReload().MustWaitLoad() + c.page.MustWaitIdle() + log.Println("[购物车] 刷新完成") + return nil +} + +// SelectAll 全选购物车商品 +func (c *Cart) SelectAll() error { + log.Println("[购物车] 尝试全选商品...") + + // 淘宝购物车全选按钮,尝试多种选择器兼容 + selectors := []string{ + // 常见的全选 checkbox 选择器 + `#cart-operation-fixed > div.cartOperationLeft--JnN5kBCf > label > span.ant-checkbox.ant-checkbox-checked > input[type="checkbox"]`, + } + + for _, sel := range selectors { + el, err := c.page.Timeout(3 * time.Second).Element(sel) + if err == nil && el != nil { + el.MustClick() + log.Printf("[购物车] 已点击全选按钮: %s", sel) + time.Sleep(500 * time.Millisecond) + return nil + } + } + + // 兜底方案:通过 JS 全选 + log.Println("[购物车] CSS 选择器未匹配,尝试 JS 全选...") + c.page.MustEval(`() => { + // 查找所有未选中的 checkbox 并点击 + const checkboxes = document.querySelectorAll('input[type="checkbox"]:not(:checked)'); + checkboxes.forEach(cb => cb.click()); + }`) + + return nil +} + +// ClickCheckout 点击"结算"按钮 +func (c *Cart) ClickCheckout() error { + log.Println("[购物车] 查找结算按钮...") + + // 按稳定性排序: class 选择器 > 容器内查找 > 文本匹配 + selectors := []string{ + "div.trade-cart-btn-submit div.btn--QDjHtErD", // 精确 class 路径 + "div.btnWrap--hJoXF9T5 div.btn--QDjHtErD", // 备用 wrapper class + "#settlementContainer_1 div.btn--QDjHtErD", // 容器 ID + class + "div[class*='trade-cart-btn-submit'] div[class*='btn--']", // 模糊匹配 + } + + for _, sel := range selectors { + el, err := c.page.Timeout(3 * time.Second).Element(sel) + if err == nil && el != nil { + el.MustClick() + log.Printf("[购物车] 已点击结算按钮: %s", sel) + return nil + } + } + + // 兆底: 通过文本内容查找结算按钮 + log.Println("[购物车] 尝试通过文本内容查找结算按钮...") + el, err := c.page.Timeout(3*time.Second).ElementR("div", "结算") + if err == nil && el != nil { + el.MustClick() + log.Println("[购物车] 已通过文本匹配点击结算按钮") + return nil + } + + return fmt.Errorf("未找到结算按钮") +} + +// HoverCheckout 将鼠标悬停在结算按钮上(预热,减少点击延迟) +func (c *Cart) HoverCheckout() { + log.Println("[购物车] 预热:悬停结算按钮...") + + selectors := []string{ + "div.trade-cart-btn-submit div.btn--QDjHtErD", + "div.btnWrap--hJoXF9T5 div.btn--QDjHtErD", + "#settlementContainer_1 div.btn--QDjHtErD", + } + + for _, sel := range selectors { + el, err := c.page.Timeout(2 * time.Second).Element(sel) + if err == nil && el != nil { + el.MustHover() + log.Printf("[购物车] 已悬停结算按钮: %s", sel) + return + } + } + log.Println("[购物车] 预热:未找到结算按钮") +} + +// DisableAnimations 注入 JS 禁用页面动画,加速交互 +func (c *Cart) DisableAnimations() { + c.page.MustEval(`() => { + const style = document.createElement('style'); + style.textContent = '*, *::before, *::after { transition: none !important; animation: none !important; }'; + document.head.appendChild(style); + }`) + log.Println("[购物车] 已禁用页面动画") +} diff --git a/services/order.go b/services/order.go new file mode 100644 index 0000000..b20adde --- /dev/null +++ b/services/order.go @@ -0,0 +1,164 @@ +package services + +import ( + "fmt" + "log" + "strings" + "time" + + "github.com/go-rod/rod" +) + +// OrderResult 下单结果 +type OrderResult struct { + Success bool + Message string +} + +// Order 订单提交服务 +type Order struct { + page *rod.Page +} + +// NewOrder 创建订单服务 +func NewOrder(page *rod.Page) *Order { + return &Order{page: page} +} + +// 提交订单按钮选择器列表 +var submitSelectors = []string{ + "#submitOrder > div > div.btnBox--p9CumEtE > div.btn--QDjHtErD", // 淘宝新版提交按钮 +} + +// WaitForConfirmPage 等待订单确认页加载 +func (o *Order) WaitForConfirmPage(timeout time.Duration) error { + log.Println("[订单] 等待订单确认页加载...") + + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + url := o.page.MustEval("() => window.location.href").String() + if o.isConfirmPage(url) { + log.Printf("[订单] 已进入订单确认页: %s", url) + o.page.MustWaitLoad() + time.Sleep(500 * time.Millisecond) + return nil + } + time.Sleep(100 * time.Millisecond) + } + + return fmt.Errorf("等待订单确认页超时") +} + +// Refresh 刷新当前订单确认页 +func (o *Order) Refresh() { + log.Println("[订单] 刷新订单确认页...") + o.page.MustReload().MustWaitLoad() + o.page.MustWaitIdle() + log.Println("[订单] 刷新完成") +} + +// CanSubmit 检测当前订单页是否可以提交(按钮存在且未被禁用) +func (o *Order) CanSubmit() bool { + for _, sel := range submitSelectors { + el, err := o.page.Timeout(1 * time.Second).Element(sel) + if err != nil || el == nil { + continue + } + + // 检查按钮是否被禁用(class 含 disabled / 属性 disabled 等) + className, _ := el.Attribute("class") + if className != nil && strings.Contains(*className, "disabled") { + log.Printf("[订单] 按钮存在但被禁用: %s", sel) + return false + } + + disabledAttr, _ := el.Attribute("disabled") + if disabledAttr != nil { + log.Printf("[订单] 按钮存在但 disabled 属性: %s", sel) + return false + } + + log.Printf("[订单] ✅ 按钮可点击: %s", sel) + return true + } + + // 还可以检查页面上是否有"该商品暂时无法购买"之类的提示 + pageText := o.page.MustEval(`() => document.body.innerText`).String() + if strings.Contains(pageText, "无法购买") || + strings.Contains(pageText, "暂不可售") || + strings.Contains(pageText, "已下架") || + strings.Contains(pageText, "未开始") { + log.Println("[订单] 页面提示商品暂时无法购买") + return false + } + + log.Println("[订单] 未找到提交按钮") + return false +} + +// RefreshUntilCanSubmit 循环刷新订单页,直到可以提交,返回时表示可以立即点击提交 +func (o *Order) RefreshUntilCanSubmit(interval time.Duration, maxAttempts int) bool { + for i := 1; i <= maxAttempts; i++ { + log.Printf("[订单] 第 %d/%d 次检测是否可购买...", i, maxAttempts) + + if o.CanSubmit() { + log.Println("[订单] ✅ 商品可购买!") + return true + } + + if i < maxAttempts { + time.Sleep(interval) + o.Refresh() + } + } + + log.Println("[订单] 已达最大检测次数,商品仍不可购买") + return false +} + +// Submit 点击"提交订单"按钮 +func (o *Order) Submit() *OrderResult { + log.Println("[订单] 点击提交订单...") + + for _, sel := range submitSelectors { + el, err := o.page.Timeout(2 * time.Second).Element(sel) + if err == nil && el != nil { + el.MustClick() + log.Printf("[订单] 已点击提交按钮: %s", sel) + time.Sleep(1 * time.Second) + return o.checkResult() + } + } + + return &OrderResult{ + Success: false, + Message: "未找到提交订单按钮", + } +} + +// checkResult 检查下单结果 +func (o *Order) checkResult() *OrderResult { + time.Sleep(2 * time.Second) + url := o.page.MustEval("() => window.location.href").String() + log.Printf("[订单] 提交后 URL: %s", url) + + if strings.Contains(url, "cashier") || + strings.Contains(url, "pay") || + strings.Contains(url, "alipay") { + return &OrderResult{ + Success: true, + Message: "抢购成功!请尽快完成付款", + } + } + + return &OrderResult{ + Success: false, + Message: fmt.Sprintf("提交后仍在: %s", url), + } +} + +// isConfirmPage 判断 URL 是否为订单确认页 +func (o *Order) isConfirmPage(url string) bool { + return strings.Contains(url, "buy.taobao.com") || + strings.Contains(url, "buyertrade") +} diff --git a/services/timer.go b/services/timer.go new file mode 100644 index 0000000..985f7b7 --- /dev/null +++ b/services/timer.go @@ -0,0 +1,112 @@ +package services + +import ( + "log" + "runtime" + "time" + + "go-taobao/config" + "go-taobao/utils" +) + +// Timer 定时触发器,精确控制抢购时机 +type Timer struct { + cfg *config.RushConfig + timeSync *utils.TimeSync +} + +// NewTimer 创建定时器 +func NewTimer(cfg *config.RushConfig, ts *utils.TimeSync) *Timer { + return &Timer{cfg: cfg, timeSync: ts} +} + +// WaitUntilPreload 等待到预加载时间点(目标时间 - PreloadBefore) +// 返回后应立即开始预加载购物车 +func (t *Timer) WaitUntilPreload() { + preloadTime := t.cfg.TargetTime.Add(-t.cfg.PreloadBefore) + remaining := t.timeSync.UntilTarget(preloadTime) + + if remaining <= 0 { + log.Println("[定时器] 已过预加载时间,立即执行") + return + } + + log.Printf("[定时器] 距离预加载还有 %v,等待中...", remaining.Round(time.Second)) + t.sleepUntil(preloadTime) + log.Println("[定时器] 预加载时间到!") +} + +// WaitUntilRush 等待到抢购时间点(目标时间 - SubmitAdvance) +// 使用 Sleep + 自旋忙等 的混合策略确保毫秒级精度 +func (t *Timer) WaitUntilRush() { + // 实际触发时间 = 目标时间 - 提前量(补偿网络延迟) + rushTime := t.cfg.TargetTime.Add(-t.cfg.SubmitAdvance) + remaining := t.timeSync.UntilTarget(rushTime) + + if remaining <= 0 { + log.Println("[定时器] 已过抢购时间,立即执行") + return + } + + log.Printf("[定时器] 距离抢购还有 %v (含 %v 提前量)", + remaining.Round(time.Millisecond), t.cfg.SubmitAdvance) + + t.sleepUntil(rushTime) + + serverNow := t.timeSync.Now() + diff := serverNow.Sub(t.cfg.TargetTime) + log.Printf("[定时器] 触发! 服务器时间: %s (偏差: %v)", + serverNow.Format("15:04:05.000"), diff) +} + +// sleepUntil 混合等待策略:先 Sleep 粗等,最后 3 秒自旋忙等 +func (t *Timer) sleepUntil(target time.Time) { + // 阶段1: 距离目标 >5s 时,用 Sleep 粗等(省 CPU) + for { + remaining := t.timeSync.UntilTarget(target) + if remaining <= 3*time.Second { + break + } + // 每次 sleep 剩余时间的一半,逐步逼近 + sleepDur := remaining / 2 + if sleepDur > 1*time.Second { + sleepDur = 1 * time.Second + } + time.Sleep(sleepDur) + } + + // 阶段2: 最后 3 秒,锁定 OS 线程 + 自旋忙等(最高精度) + runtime.LockOSThread() + defer runtime.UnlockOSThread() + + log.Println("[定时器] 进入自旋忙等模式...") + for t.timeSync.Now().Before(target) { + // 自旋,不让出 CPU + runtime.Gosched() // 偶尔让出一下,避免完全饿死其他 goroutine + } +} + +// PrintCountdown 持续打印倒计时日志(在单独的 goroutine 中运行) +func (t *Timer) PrintCountdown(done <-chan struct{}) { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for { + select { + case <-done: + return + case <-ticker.C: + remaining := t.timeSync.UntilTarget(t.cfg.TargetTime) + if remaining <= 0 { + return + } + + hours := int(remaining.Hours()) + mins := int(remaining.Minutes()) % 60 + secs := int(remaining.Seconds()) % 60 + log.Printf("[倒计时] %02d:%02d:%02d (服务器时间: %s)", + hours, mins, secs, + t.timeSync.Now().Format("15:04:05")) + } + } +} diff --git a/utils/timesync.go b/utils/timesync.go new file mode 100644 index 0000000..095e857 --- /dev/null +++ b/utils/timesync.go @@ -0,0 +1,216 @@ +package utils + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "sort" + "strconv" + "strings" + "time" + + "go-taobao/config" +) + +// TimeSync 时间同步器,计算本地时钟与服务器的时间偏移 +type TimeSync struct { + // offset = 服务器时间 - 本地时间 + Offset time.Duration + // 使用的时间源名称 + Source string +} + +// taobaoH5Resp 淘宝 H5 时间 API 响应 +type taobaoH5Resp struct { + Data struct { + T string `json:"t"` + } `json:"data"` +} + +// suningTimeResp 苏宁时间 API 响应 +type suningTimeResp struct { + SysTime2 string `json:"sysTime2"` // 格式: 2026-02-09 14:30:00 +} + +// NewTimeSync 创建时间同步器,依次尝试多个时间源 +func NewTimeSync(samples int) (*TimeSync, error) { + if samples < 1 { + samples = 5 + } + + // 依次尝试每个时间源 + for _, api := range config.TimeAPIs { + log.Printf("[时间同步] 尝试时间源: %s (%s)", api.Name, api.URL) + + offsets := make([]time.Duration, 0, samples) + for i := 0; i < samples; i++ { + offset, err := fetchOffset(api) + if err != nil { + log.Printf("[时间同步] %s 第 %d 次采样失败: %v", api.Name, i+1, err) + continue + } + offsets = append(offsets, offset) + log.Printf("[时间同步] %s 第 %d 次采样: 偏移 = %v", api.Name, i+1, offset) + + if i < samples-1 { + time.Sleep(200 * time.Millisecond) + } + } + + if len(offsets) == 0 { + log.Printf("[时间同步] %s 所有采样失败,尝试下一个时间源", api.Name) + continue + } + + // 取中位数 + sort.Slice(offsets, func(i, j int) bool { + return offsets[i] < offsets[j] + }) + median := offsets[len(offsets)/2] + + ts := &TimeSync{Offset: median, Source: api.Name} + log.Printf("[时间同步] ✅ 使用 %s, 偏移量: %v (%d 次有效采样)", + api.Name, median, len(offsets)) + return ts, nil + } + + // 所有时间源都失败,降级到本地时间 + log.Println("[时间同步] ⚠️ 所有时间源均失败,降级使用本地时间 (偏移=0)") + return &TimeSync{Offset: 0, Source: "本地时间(降级)"}, nil +} + +// fetchOffset 根据 API 类型获取单次时间偏移 +func fetchOffset(api config.TimeAPIConfig) (time.Duration, error) { + client := &http.Client{Timeout: 5 * time.Second} + + localBefore := time.Now() + + req, err := http.NewRequest("GET", api.URL, nil) + if err != nil { + return 0, fmt.Errorf("创建请求失败: %w", err) + } + // 模拟浏览器请求头 + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + + resp, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("请求失败: %w", err) + } + defer resp.Body.Close() + + localAfter := time.Now() + rtt := localAfter.Sub(localBefore) + localEstimate := localBefore.Add(rtt / 2) + + var serverTime time.Time + + switch api.Type { + case config.TimeAPITypeTaobaoH5: + serverTime, err = parseTaobaoH5(resp) + case config.TimeAPITypeSuning: + serverTime, err = parseSuning(resp) + case config.TimeAPITypeHTTPHeader: + serverTime, err = parseHTTPDate(resp) + default: + return 0, fmt.Errorf("未知 API 类型") + } + + if err != nil { + return 0, err + } + + offset := serverTime.Sub(localEstimate) + log.Printf("[时间同步] RTT: %v, 服务器时间: %s", rtt, serverTime.Format("15:04:05.000")) + return offset, nil +} + +// parseTaobaoH5 解析淘宝 H5 API 响应 +func parseTaobaoH5(resp *http.Response) (time.Time, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return time.Time{}, fmt.Errorf("读取响应失败: %w", err) + } + + // 淘宝 H5 API 可能返回 JSONP,需要提取 JSON 部分 + bodyStr := string(body) + jsonStr := bodyStr + if idx := strings.Index(bodyStr, "("); idx != -1 { + jsonStr = bodyStr[idx+1:] + if endIdx := strings.LastIndex(jsonStr, ")"); endIdx != -1 { + jsonStr = jsonStr[:endIdx] + } + } + + var result taobaoH5Resp + if err := json.Unmarshal([]byte(jsonStr), &result); err != nil { + return time.Time{}, fmt.Errorf("解析 JSON 失败: %w (body: %s)", err, bodyStr[:min(len(bodyStr), 200)]) + } + + if result.Data.T == "" { + return time.Time{}, fmt.Errorf("时间戳为空 (body: %s)", bodyStr[:min(len(bodyStr), 200)]) + } + + ms, err := strconv.ParseInt(result.Data.T, 10, 64) + if err != nil { + return time.Time{}, fmt.Errorf("解析时间戳失败: %w", err) + } + + return time.UnixMilli(ms), nil +} + +// parseSuning 解析苏宁时间 API 响应 +func parseSuning(resp *http.Response) (time.Time, error) { + body, err := io.ReadAll(resp.Body) + if err != nil { + return time.Time{}, fmt.Errorf("读取响应失败: %w", err) + } + + var result suningTimeResp + if err := json.Unmarshal(body, &result); err != nil { + return time.Time{}, fmt.Errorf("解析 JSON 失败: %w", err) + } + + if result.SysTime2 == "" { + return time.Time{}, fmt.Errorf("时间字段为空") + } + + loc, _ := time.LoadLocation("Asia/Shanghai") + t, err := time.ParseInLocation("2006-01-02 15:04:05", result.SysTime2, loc) + if err != nil { + return time.Time{}, fmt.Errorf("解析时间失败: %w", err) + } + + return t, nil +} + +// parseHTTPDate 从 HTTP 响应头 Date 字段解析服务器时间 +func parseHTTPDate(resp *http.Response) (time.Time, error) { + dateStr := resp.Header.Get("Date") + if dateStr == "" { + return time.Time{}, fmt.Errorf("响应头无 Date 字段") + } + + // HTTP Date 格式: Thu, 09 Feb 2026 06:30:00 GMT + t, err := time.Parse(time.RFC1123, dateStr) + if err != nil { + // 尝试备用格式 + t, err = time.Parse(time.RFC1123Z, dateStr) + if err != nil { + return time.Time{}, fmt.Errorf("解析 Date 头失败: %w (值: %s)", err, dateStr) + } + } + + return t, nil +} + +// Now 返回校准后的当前时间 +func (ts *TimeSync) Now() time.Time { + return time.Now().Add(ts.Offset) +} + +// UntilTarget 计算距离目标时间还有多久 +func (ts *TimeSync) UntilTarget(target time.Time) time.Duration { + return target.Sub(ts.Now()) +}