Files
yixiaogao/backend/cmd/main.go
2025-11-27 18:40:08 +08:00

649 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"fmt"
"io/ioutil"
"log"
"net/url"
"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, "<body[^>]*>(.*?)</body>")
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参数")
}
// URL解码biz参数
biz, err = url.QueryUnescape(biz)
if err != nil {
fmt.Printf("警告: URL解码__biz失败: %v使用原始值\n", err)
}
uin, err := utils.ExtractFromRegex(accessToken, "uin=([^&]*)")
if err != nil {
return "", "", "", "", fmt.Errorf("未找到uin参数")
}
// URL解码uin参数
uin, err = url.QueryUnescape(uin)
if err != nil {
fmt.Printf("警告: URL解码uin失败: %v使用原始值\n", err)
}
key, err := utils.ExtractFromRegex(accessToken, "key=([^&]*)")
if err != nil {
return "", "", "", "", fmt.Errorf("未找到key参数")
}
// URL解码key参数
key, err = url.QueryUnescape(key)
if err != nil {
fmt.Printf("警告: URL解码key失败: %v使用原始值\n", err)
}
passTicket, err := utils.ExtractFromRegex(accessToken, "pass_ticket=([^&]*)")
if err != nil {
return "", "", "", "", fmt.Errorf("未找到pass_ticket参数")
}
// URL解码pass_ticket参数
passTicket, err = url.QueryUnescape(passTicket)
if err != nil {
fmt.Printf("警告: URL解码pass_ticket失败: %v使用原始值\n", err)
}
// 打印解码后的参数用于调试
fmt.Printf("\n提取到的参数已解码\n")
fmt.Printf(" __biz: %s\n", biz)
fmt.Printf(" uin: %s\n", uin)
fmt.Printf(" key长度: %d 字符\n", len(key))
fmt.Printf(" pass_ticket长度: %d 字符\n", len(passTicket))
return biz, uin, key, passTicket, nil
}