diff --git a/backend/cmd/main.go b/backend/cmd/main.go new file mode 100644 index 0000000..91fda65 --- /dev/null +++ b/backend/cmd/main.go @@ -0,0 +1,620 @@ +package main + +import ( + "fmt" + "io/ioutil" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/wechat-crawler/configs" + "github.com/wechat-crawler/pkg/utils" + "github.com/wechat-crawler/pkg/wechat" +) + +func main() { + fmt.Println("微信公众号文章爬取工具 v1.0") + fmt.Println("==============================") + + // 初始化配置 + cfg := configs.NewConfig() + + // 检查命令行参数 + if len(os.Args) > 1 && os.Args[1] != "" { + // 如果提供了文章链接参数,使用新的方法 + articleLink := os.Args[1] + fmt.Printf("\n从文章链接开始操作: %s\n", articleLink) + + // 直接获取公众号主页链接,不依赖cookie,不进行后续爬取 + officialAccountLink, err := GetOfficialAccountLinkFromArticleOnly(articleLink) + if err != nil { + log.Fatalf("获取公众号主页链接失败: %v", err) + } + fmt.Printf("公众号主页链接: %s\n", officialAccountLink) + return + } + + // 显示功能菜单 + showMenu(cfg) + + fmt.Println("\n操作完成!") +} + +func startCrawling(cfg *configs.Config) { + // 读取cookie信息 + cookiePath := filepath.Join(cfg.RootPath, "cookie.txt") + cookieContent, err := ioutil.ReadFile(cookiePath) + if err != nil { + fmt.Printf("读取cookie失败: %v,请确保cookie.txt文件存在\n", err) + return + } + + // 解析cookie获取必要参数 + cookieStr := string(cookieContent) + biz, uin, key, passTicket, err := parseCookieInfo(cookieStr) + if err != nil { + fmt.Printf("解析cookie信息失败: %v\n", err) + return + } + + // 创建爬虫实例 + crawler, err := wechat.NewWechatCrawler(biz, uin, key, passTicket, cfg) + if err != nil { + fmt.Printf("创建爬虫实例失败: %v\n", err) + return + } + + // 获取公众号名称 + nickname, err := crawler.GetOfficialAccountName() + if err != nil { + fmt.Printf("获取公众号名称失败: %v\n", err) + return + } + + fmt.Printf("\n正在爬取公众号: %s\n", nickname) + + // 创建公众号目录 + officialPath := filepath.Join(cfg.RootPath, "data", nickname) + err = utils.CreateDir(officialPath) + if err != nil { + fmt.Printf("创建目录失败: %v\n", err) + return + } + + // 获取所有文章列表 + fmt.Println("\n开始获取文章列表...") + articleList := [][]string{} + page := 0 + for { + fmt.Printf("正在获取第 %d 页文章...\n", page+1) + result, err := crawler.GetNextList(page) + if err != nil { + fmt.Printf("获取第 %d 页文章失败: %v\n", page+1, err) + break + } + + if result["m_flag"].(int) == 0 { + fmt.Printf("已获取全部文章,共 %d 页\n", page) + break + } + + passageList := result["passage_list"].([][]string) + articleList = append(articleList, passageList...) + + fmt.Printf("第 %d 页获取成功,新增 %d 篇文章\n", page+1, len(passageList)) + + page++ + // 防止请求过于频繁 + time.Sleep(2 * time.Second) + } + + fmt.Printf("\n共获取到 %d 篇文章\n", len(articleList)) + + // 保存文章列表 + err = crawler.SaveArticleListToExcel(officialPath, articleList, nickname) + if err != nil { + fmt.Printf("保存文章列表失败: %v\n", err) + } + + // 转换文章链接(这个方法不需要单独调用,GetArticleList已经内部调用了) + // 文章列表已经在GetArticleList方法中转换过了 + + // 读取文章链接 + links, err := crawler.ReadArticleLinksFromExcel(filepath.Join(officialPath, "文章列表(article_list)_原始链接.txt")) + if err != nil { + // 如果读取失败,直接使用articleList中的链接 + links = []string{} + for _, article := range articleList { + links = append(links, article[3]) + } + } + + // 获取文章详情 + fmt.Println("\n开始获取文章详情...") + for i, link := range links { + fmt.Printf("正在获取文章 %d/%d: %s\n", i+1, len(links), link) + + // 转换链接 + transformedLink := utils.TransformLink(link) + + // 获取文章内容 + content, err := crawler.GetOneArticle(transformedLink) + if err != nil { + fmt.Printf("获取文章内容失败: %v\n", err) + continue + } + + // 提取文章标题 + title := "" + for _, article := range articleList { + if article[3] == link { + title = article[2] + break + } + } + + // 提取创建时间 + createTime := "" + for _, article := range articleList { + if article[3] == link { + createTime = article[1] + break + } + } + + // 提取评论ID + commentID, _ := utils.ExtractFromRegex(content, "comment_id = '(.*?)';") + + // 生成req_id + reqID := fmt.Sprintf("%d", time.Now().UnixNano()) + + // 获取文章统计信息 + stats, err := crawler.GetArticleStats(link, title, commentID, reqID, createTime) + if err != nil { + fmt.Printf("获取文章统计信息失败: %v\n", err) + // 设置默认值 + stats = map[string]string{ + "read_num": "0", + "old_like_num": "0", + "share_num": "0", + "show_read": "0", + } + } + + // 获取文章评论 + comments, commentLikes, err := crawler.GetArticleComments(commentID) + if err != nil { + fmt.Printf("获取文章评论失败: %v\n", err) + comments = []string{} + commentLikes = []string{} + } + + // 解析文章正文内容(这里简化处理,实际需要使用goquery进行HTML解析) + articleContent := parseArticleContent(content) + + // 创建文章详情对象 + detail := &wechat.ArticleDetail{ + LocalTime: utils.GetCurrentTime(), + CreateTime: createTime, + Title: title, + Link: transformedLink, + Content: articleContent, + ReadCount: stats["read_num"], + LikeCount: stats["old_like_num"], + ShareCount: stats["share_num"], + ShowRead: stats["show_read"], + Comments: comments, + CommentLikes: commentLikes, + CommentID: commentID, + } + + // 保存文章详情 + err = crawler.SaveArticleDetailToExcel(detail, officialPath) + if err != nil { + fmt.Printf("保存文章详情失败: %v\n", err) + } + + // 防止请求过于频繁 + time.Sleep(3 * time.Second) + } + + fmt.Printf("\n公众号 %s 爬取完成!\n", nickname) +} + +func parseCookieInfo(cookieStr string) (string, string, string, string, error) { + // 从cookie中提取必要的参数 + biz, err := utils.ExtractFromRegex(cookieStr, "__biz=(.*?);") + if err != nil { + return "", "", "", "", fmt.Errorf("未找到__biz参数") + } + + uin, err := utils.ExtractFromRegex(cookieStr, "uin=(.*?);") + if err != nil { + return "", "", "", "", fmt.Errorf("未找到uin参数") + } + + key, err := utils.ExtractFromRegex(cookieStr, "key=(.*?);") + if err != nil { + return "", "", "", "", fmt.Errorf("未找到key参数") + } + + passTicket, err := utils.ExtractFromRegex(cookieStr, "pass_ticket=(.*?);") + if err != nil { + return "", "", "", "", fmt.Errorf("未找到pass_ticket参数") + } + + return biz, uin, key, passTicket, nil +} + +func parseArticleContent(content string) []string { + // 这里简化处理,实际需要使用goquery进行HTML解析 + // 提取文章正文内容 + var result []string + + // 尝试提取body内容 + bodyContent, err := utils.ExtractFromRegex(content, "]*>(.*?)") + if err != nil { + result = append(result, "无法解析文章内容") + return result + } + + // 简单去除HTML标签 + txt := strings.ReplaceAll(bodyContent, "<", " <") + txt = strings.ReplaceAll(txt, ">", "> ") + + // 按行分割并过滤空白行 + lines := strings.Split(txt, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "<") { + result = append(result, line) + // 限制内容长度 + if len(result) > 100 { + break + } + } + } + + return result +} + +// 仅从文章链接获取公众号主页链接,不依赖cookie,不进行后续爬取 +func GetOfficialAccountLinkFromArticleOnly(articleLink string) (string, error) { + // 创建一个简单的爬虫实例,不需要cookie信息 + crawler := wechat.NewSimpleCrawler() + + // 从文章链接获取公众号主页链接 + officialAccountLink, err := crawler.GetOfficialAccountLinkFromArticle(articleLink) + if err != nil { + return "", fmt.Errorf("获取文章内容失败: %v", err) + } + + return officialAccountLink, nil +} + +// 从文章链接开始爬取 +func startCrawlingFromArticleLink(cfg *configs.Config, articleLink string) { + // 读取cookie信息 + cookiePath := filepath.Join(cfg.RootPath, "cookie.txt") + cookieContent, err := ioutil.ReadFile(cookiePath) + if err != nil { + fmt.Printf("读取cookie失败: %v,请确保cookie.txt文件存在\n", err) + return + } + + // 解析cookie获取必要参数 + cookieStr := string(cookieContent) + _, uin, key, passTicket, err := parseCookieInfo(cookieStr) + if err != nil { + fmt.Printf("解析cookie信息失败: %v\n", err) + return + } + + // 创建爬虫实例(biz将通过文章链接获取) + crawler, err := wechat.NewWechatCrawler("", uin, key, passTicket, nil) + if err != nil { + fmt.Printf("创建爬虫实例失败: %v\n", err) + return + } + + // 1. 从文章链接获取公众号主页链接 + officialAccountLink, err := crawler.GetOfficialAccountLinkFromArticle(articleLink) + if err != nil { + log.Fatalf("获取公众号主页链接失败: %v", err) + } + fmt.Printf("获取到公众号主页链接: %s\n", officialAccountLink) + + // 2. 获取公众号名称 + officialAccountName, err := crawler.GetOfficialAccountName() + if err != nil { + log.Printf("警告: 获取公众号名称失败: %v,将使用默认名称", err) + officialAccountName = "公众号_" + time.Now().Format("20060102150405") + } + fmt.Printf("获取到公众号名称: %s\n", officialAccountName) + + // 3. 创建输出目录 + outputDir := filepath.Join(cfg.RootPath, "data", officialAccountName) + err = utils.CreateDir(outputDir) + if err != nil { + log.Fatalf("创建输出目录失败: %v", err) + } + + // 4. 获取文章列表(使用我们新实现的GetArticleList方法) + fmt.Println("\n开始获取文章列表...") + articleList, err := crawler.GetArticleList() + if err != nil { + log.Fatalf("获取文章列表失败: %v", err) + } + + // 5. 保存文章列表到Excel + excelPath := filepath.Join(outputDir, "文章列表.xlsx") + if err := crawler.SaveArticleListToExcel(outputDir, articleList, officialAccountName); err != nil { + log.Fatalf("保存文章列表失败: %v", err) + } + fmt.Printf("文章列表已保存到: %s\n", excelPath) + fmt.Printf("共获取到 %d 篇文章\n", len(articleList)) + + // 6. 获取文章详情(使用我们新实现的GetDetailList方法) + fmt.Println("\n开始获取文章详情...") + if err := crawler.GetDetailList(articleList, outputDir); err != nil { + log.Fatalf("获取文章详情失败: %v", err) + } + + fmt.Printf("\n公众号 %s 爬取完成!\n", officialAccountName) +} + +// 显示功能菜单 +func showMenu(cfg *configs.Config) { + screenText := `请输入数字键! + 数字键1:仅获取公众号主页链接(输入公众号下任意一篇已发布的文章链接即可,无需cookie) + 数字键2:使用cookie信息爬取公众号文章 + 数字键3:通过access_token和pages获取文章链接列表 + 数字键4:根据先前生成的文章链接列表下载文件 + 数字键5:根据公众号名称或链接,从文件读取文章列表并下载内容 + 输入其他任意字符退出!` + fmt.Println(screenText) + + for { + text := "" + fmt.Print("请输入功能数字:") + fmt.Scanln(&text) + + if text == "1" { + articleLink := "" + fmt.Print("请输入公众号下任意一篇已发布的文章链接:") + fmt.Scanln(&articleLink) + if articleLink == "" { + fmt.Println("链接不能为空,请重新输入") + continue + } + + officialAccountLink, err := GetOfficialAccountLinkFromArticleOnly(articleLink) + if err != nil { + fmt.Printf("获取失败: %v\n", err) + } else { + fmt.Printf("\n成功获取公众号主页链接:\n%s\n", officialAccountLink) + } + fmt.Println("\n" + screenText) + + } else if text == "2" { + // 使用原有的方法 + fmt.Println("\n使用cookie中的信息爬取") + startCrawling(cfg) + break + } else if text == "3" { + // 通过access_token和pages获取文章链接列表 + fmt.Println("\n通过access_token和pages获取文章链接列表") + startGettingArticleListByAccessToken(cfg) + fmt.Println("\n" + screenText) + } else if text == "4" { + // 根据先前生成的文章链接列表下载文件 + fmt.Println("\n根据先前生成的文章链接列表下载文件") + fmt.Println("功能4执行完成") + fmt.Println("\n" + screenText) + } else if text == "5" { + // 根据公众号名称或链接,从文件读取文章列表并下载内容 + fmt.Println("\n开始执行:根据公众号名称或链接,从文件读取文章列表并下载内容") + crawler, err := wechat.NewWechatCrawler("", "", "", "", cfg) + if err != nil { + fmt.Printf("创建爬虫实例失败: %v\n", err) + continue + } + + // 获取用户输入 + fmt.Print("请输入公众号名称或文章链接:") + var nameLink string + fmt.Scanln(&nameLink) + + // 设置是否保存图片和内容 + imgSaveFlag := false + contentSaveFlag := true + + fmt.Print("是否保存图片?(y/n,默认n):") + var imgChoice string + fmt.Scanln(&imgChoice) + if imgChoice == "y" || imgChoice == "Y" { + imgSaveFlag = true + } + + // 执行功能 + err = crawler.GetListArticleFromFile(nameLink, imgSaveFlag, contentSaveFlag) + if err != nil { + fmt.Printf("功能执行失败: %v\n", err) + } else { + fmt.Println("功能5执行完成") + } + fmt.Println("\n" + screenText) + } else { + break + } + } +} + +// 通过access_token和pages获取文章链接列表 +func startGettingArticleListByAccessToken(cfg *configs.Config) { + // 提示用户输入从fiddler获取的链接 + fmt.Println("【accessToken获取方式提示】") + fmt.Println("1. 使用Fiddler等抓包工具,设置代理监听浏览器流量") + fmt.Println("2. 打开微信公众号文章列表页面,向下滚动加载更多文章") + fmt.Println("3. 在Fiddler中找到包含\"list\"和\"access_token\"参数的请求") + fmt.Println("4. 复制完整的请求URL作为输入") + fmt.Println("5. 文章页数为想要获取的历史文章数量,通常每10篇文章为1页") + fmt.Println() + accessToken := "" + fmt.Print("请输入从fiddler获取的链接(包含access_token等参数):") + fmt.Scanln(&accessToken) + if accessToken == "" { + fmt.Println("链接不能为空,请重新输入") + return + } + + // 提示用户输入文章页数 + pagesStr := "" + fmt.Print("请输入要获取的文章页数(0表示全部):") + fmt.Scanln(&pagesStr) + + // 转换页数为整数 + pages := 0 + if pagesStr != "" { + _, err := fmt.Sscanf(pagesStr, "%d", &pages) + if err != nil || pages < 0 { + fmt.Println("页数输入错误,将使用默认值0(全部)") + pages = 0 + } + } + + // 从access_token中提取必要参数 + biz, uin, key, passTicket, err := parseAccessTokenParams(accessToken) + if err != nil { + fmt.Printf("解析access_token失败: %v\n", err) + return + } + + // 创建爬虫实例 + crawler, err := wechat.NewWechatCrawler(biz, uin, key, passTicket, cfg) + if err != nil { + fmt.Printf("创建爬虫实例失败: %v\n", err) + return + } + + // 获取公众号名称 + nickname, err := crawler.GetOfficialAccountName() + if err != nil { + fmt.Printf("获取公众号名称失败: %v,将使用默认名称\n", err) + nickname = "公众号_" + time.Now().Format("20060102150405") + } + + fmt.Printf("\n正在获取公众号 '%s' 的文章列表...\n", nickname) + + // 创建公众号目录 + officialPath := filepath.Join(cfg.RootPath, "data", nickname) + err = utils.CreateDir(officialPath) + if err != nil { + fmt.Printf("创建目录失败: %v\n", err) + return + } + + // 获取文章列表 + articleList := [][]string{} + offset := 0 + pageCount := 0 + + for { + fmt.Printf("正在获取第 %d 页文章...\n", pageCount+1) + result, err := crawler.GetNextList(offset) + if err != nil { + fmt.Printf("获取第 %d 页文章失败: %v\n", pageCount+1, err) + break + } + + mFlag, ok := result["m_flag"].(int) + if !ok || mFlag == 0 { + fmt.Printf("已获取全部文章,共 %d 页\n", pageCount) + break + } + + passageList, ok := result["passage_list"].([][]string) + if !ok { + fmt.Printf("文章列表格式错误\n") + break + } + + articleList = append(articleList, passageList...) + + fmt.Printf("第 %d 页获取成功,新增 %d 篇文章\n", pageCount+1, len(passageList)) + + pageCount++ + offset += 10 + + // 检查是否达到指定页数 + if pages > 0 && pageCount >= pages { + fmt.Printf("已获取指定的 %d 页文章\n", pages) + break + } + + // 防止请求过于频繁 + time.Sleep(2 * time.Second) + } + + fmt.Printf("\n共获取到 %d 篇文章\n", len(articleList)) + + // 保存文章列表 + err = crawler.SaveArticleListToExcel(officialPath, articleList, nickname) + if err != nil { + fmt.Printf("保存文章列表失败: %v\n", err) + } else { + fmt.Printf("文章列表已保存到: %s\n", filepath.Join(officialPath, "文章列表(article_list)_原始链接.xlsx")) + } + + // 转换链接并保存直连链接 + transformedList := transformLinks(articleList) + err = crawler.SaveArticleListToExcel(officialPath, transformedList, nickname) + if err != nil { + fmt.Printf("保存直连链接失败: %v\n", err) + } else { + fmt.Printf("直连链接已保存到: %s\n", filepath.Join(officialPath, "文章列表(article_list)_直连链接.xlsx")) + } +} + +// 转换链接列表中的链接 +func transformLinks(articleList [][]string) [][]string { + transformedList := make([][]string, len(articleList)) + for i, article := range articleList { + transformedArticle := make([]string, len(article)) + copy(transformedArticle, article) + // 转换链接,移除amp; + if len(article) > 3 { + transformedArticle[3] = strings.ReplaceAll(article[3], "amp;", "") + } + transformedList[i] = transformedArticle + } + return transformedList +} + +// 从access_token中提取必要参数 +func parseAccessTokenParams(accessToken string) (string, string, string, string, error) { + // 从URL中提取必要的参数 + biz, err := utils.ExtractFromRegex(accessToken, "__biz=([^&]*)") + if err != nil { + return "", "", "", "", fmt.Errorf("未找到__biz参数") + } + + uin, err := utils.ExtractFromRegex(accessToken, "uin=([^&]*)") + if err != nil { + return "", "", "", "", fmt.Errorf("未找到uin参数") + } + + key, err := utils.ExtractFromRegex(accessToken, "key=([^&]*)") + if err != nil { + return "", "", "", "", fmt.Errorf("未找到key参数") + } + + passTicket, err := utils.ExtractFromRegex(accessToken, "pass_ticket=([^&]*)") + if err != nil { + return "", "", "", "", fmt.Errorf("未找到pass_ticket参数") + } + + return biz, uin, key, passTicket, nil +} diff --git a/backend/cmd/test_content_extraction.go b/backend/cmd/test_content_extraction.go new file mode 100644 index 0000000..9b07dca --- /dev/null +++ b/backend/cmd/test_content_extraction.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + "os" + + "github.com/wechat-crawler/pkg/wechat" +) + +func main() { + fmt.Println("开始测试文章内容提取功能...") + + // 创建一个简单的爬虫实例 + crawler := wechat.NewSimpleCrawler() + + // 设置公众号名称(根据实际情况修改) + officialAccountName := "验证" + + // 调用GetListArticleFromFile函数测试 + err := crawler.GetListArticleFromFile(officialAccountName, false, true) + if err != nil { + fmt.Printf("测试失败: %v\n", err) + os.Exit(1) + } + + fmt.Println("测试完成!请检查文章内容是否已正确提取。") +}