高效抓取商品分类图标:Java爬虫WebCollector与jsoup结合应用

更新:11-15 现代故事 我要投稿 纠错 投诉

技术选型:

WebCollector+jsoup,WebCollector进行爬取,jsoup进行html解析

实现步骤:

1.根据root url发起请求,

2.获取响应页面数据,

3. 解析并提取页面数据

4.下载并保存镜像

代码实现

1.相关封装结构说明

2.代码示例

links.java类,存放已访问过的URL路径和待访问的URL路径;

包com.etoak.crawl.link;

导入java.util.HashSet;

导入java.util.LinkedList;

导入java.util.Set;

/*

* 链接主要功能;

* 1: 存储已访问过的URL路径和待访问的URL路径;

* */

公开课链接{

//已访问过的url集合。主要考虑是不能重复。使用set保证不重复;

私有静态Set VisitedUrlSet=new HashSet();

//要访问的url集合。访问主要考虑的是1:规定访问顺序; 2: 确保不提供重复的访问地址;

私有静态LinkedList unVisitedUrlQueue=new LinkedList();

//获取访问过的URL数量

公共静态int getVisitedUrlNum() {

返回visitUrlSet.size();

}

//添加到访问过的URL

公共静态无效addVisitedUrlSet(字符串url){

已访问UrlSet.add(url);

}

//删除访问过的URL

公共静态无效removeVisitedUrlSet(字符串url){

VisitUrlSet.remove(url);

}

//获取要访问的url集合

公共静态LinkedList getUnVisitedUrlQueue() {

返回未访问的UrlQueue;

}

//添加到要访问的集合中,保证每个URL只被访问一次

公共静态无效addUnvisitedUrlQueue(字符串url){

if (url !=null !url.trim().equals("") !visitedUrlSet.contains(url) !unVisitedUrlQueue.contains(url)){

unVisitedUrlQueue.add(url);

}

}

//删除要访问的url

公共静态对象removeHeadOfUnVisitedUrlQueue(){

返回unVisitedUrlQueue.removeFirst();

}

//判断未访问的URL队列是否为空

公共静态布尔unVisitedUrlQueueIsEmpty(){

返回unVisitedUrlQueue.isEmpty();

}

}

Page.java类,保存获取到的响应的相关内容;

包com.etoak.crawl.page;

导入com.etoak.crawl.util.CharsetDetector;

导入org.jsoup.Jsoup;

导入org.jsoup.nodes.Document;

导入java.io.UnsupportedEncodingException;

/*

* 页

* 1: 保存获取到的响应的相关内容;

* */

公开课页面{

私有字节[]内容;

私有字符串html; //网页源代码字符串

private Document doc;//网页Dom文档

private String charset;//字符编码

private String url;//url路径

private String contentType;//内容类型

公共页面(字节[]内容,字符串url,字符串内容类型){

this.内容=内容;

这个.url=url;

this.contentType=contentType;

}

公共字符串getCharset() {

返回字符集;

}

public String getUrl(){返回url;}

公共字符串getContentType(){ 返回contentType;}

公共字节[] getContent(){ 返回内容;}

/**

* 返回网页的源代码字符串

*

* @return 网页源代码字符串

*/

公共字符串getHtml() {

如果(html!=null){

返回html;

}

如果(内容==空){

返回空值;

}

如果(字符集==空){

charset=CharsetDetector.guessEncoding(内容); //根据内容猜测字符编码

}

尝试{

this.html=new String(内容, 字符集);

返回html;

} catch (UnsupportedEncodingException ex) {

例如:printStackTrace();

返回空值;

}

}

/*

* 获取文档

* */

公共文档getDoc(){

如果(文档!=空){

返回文档;

}

尝试{

this.doc=Jsoup.parse(getHtml(), url);

返回文档;

} catch (异常前) {

例如:printStackTrace();

返回空值;

}

}

}

PageParserTool.java类,处理html页面

包com.etoak.crawl.page;

导入org.jsoup.nodes.Element;

导入org.jsoup.select.Elements;

导入java.util.ArrayList;

导入java.util.HashSet;

导入java.util.Iterator;

导入java.util.Set;

公共类PageParserTool {

/* 使用选择器选择页面*/

公共静态元素选择(页面页面,字符串cssSelector){

return page.getDoc().select(cssSelector);

}

/*

* 通过css选择器获取指定元素;

*

* */

公共静态元素选择(页面页面,字符串cssSelector,int索引){

元素eles=select(page, cssSelector);

int realIndex=索引;

如果(索引0){

realIndex=eles.size() + 索引;

}

返回eles.get(realIndex);

}

/**

* 获取元素中满足选择器的链接。选择器cssSelector 必须位于特定的超链接处。

* 比如我们要提取div中所有带有ID内容的超链接,这里

* 需要定义cssSelector为div[id=content] a

* 放入set中,防止重复;

* @param css选择器

* @返回

*/

公共静态SetgetLinks(页面页面,字符串cssSelector){

Setlinks=new HashSet();

元素es=select(page, cssSelector);

迭代器iterator=es.iterator();

while(iterator.hasNext()) {

元素element=(Element) iterator.next();

if ( element.hasAttr("href") ) {

links.add(element.attr("abs:href"));

}else if( element.hasAttr("src") ){

links.add(element.attr("abs:src"));

}

}

返回链接;

}

/**

* 获取网页中满足指定css选择器的所有元素的指定属性集合

* 例如getAttrs("img[src]","abs:src") 可用于获取网页中所有图片的链接

* @param css选择器

* @参数属性名称

* @返回

*/

公共静态ArrayListgetAttrs(页面页面,字符串cssSelector,字符串attrName){

ArrayList结果=new ArrayList();

元素eles=select(page,cssSelector);

for (元素ele : eles) {

if (ele.hasAttr(attrName)) {

结果.add(ele.attr(attrName));

}

}

返回结果;

}

}

用于请求响应的RequestAndResponseTool.java类

包com.etoak.crawl.page;

导入org.apache.commons.httpclient.DefaultHttpMethodRetryHandler;

导入org.apache.commons.httpclient.HttpClient;

导入org.apache.commons.httpclient.HttpException;

导入org.apache.commons.httpclient.HttpStatus;

导入org.apache.commons.httpclient.methods.GetMethod;

导入org.apache.commons.httpclient.params.HttpMethodParams;

导入java.io.IOException;

公共类请求和响应工具{

公共静态页面sendRequestAndGetResponse(String url) {

页页=空;

//1.生成HttpClinet对象并设置参数

HttpClient httpClient=new HttpClient();

//设置HTTP连接超时5s

httpClient.getHttpConnectionManager().getParams().setConnectionTimeout(5000);

//2.生成GetMethod对象并设置参数

GetMethod getMethod=new GetMethod(url);

//设置get请求超时5s

getMethod.getParams().setParameter(HttpMethodParams.SO_TIMEOUT, 5000);

//设置请求重试处理

getMethod.getParams().setParameter(HttpMethodParams.RETRY_HANDLER, new DefaultHttpMethodRetryHandler());

//3. 执行HTTP GET请求

尝试{

int statusCode=httpClient.executeMethod(getMethod);

//判断访问状态码

if (statusCode !=HttpStatus.SC_OK) {

System.err.println("方法失败:" + getMethod.getStatusLine());

}

//4.处理HTTP响应内容

byte[] responseBody=getMethod.getResponseBody(); //读取为字节数组

String contentType=getMethod.getResponseHeader("Content-Type").getValue(); //获取当前返回类型

页面=新页面(responseBody,url,contentType); //封装成页面

} catch (HttpException e) {

//发生致命异常。可能是协议不正确或者返回内容有问题。

System.out.println("请检查您提供的http地址!");

e.printStackTrace();

} catch (IOException e) {

//发生网络异常

e.printStackTrace();

} 最后{

//释放连接

getMethod.releaseConnection();

}

返回页面;

}

}

CharsetDetector.java 类,用于字符集处理

/*

* 版权所有(C)2014hu

* 本程序是免费软件;您可以重新分发它和/或

* 根据GNU通用公共许可证的条款进行修改

* 由自由软件基金会发布;任一版本2

* 许可证,或(由您选择)任何更高版本。

* 该程序发布是为了希望它有用,

* 但不提供任何保证;甚至没有默示保证

* 适销性或特定用途的适用性。请参阅

* GNU 通用公共许可证了解更多详细信息。

* 您应该已收到GNU 通用公共许可证的副本

* 与此程序一起使用;如果没有,请写信给自由软件

* Foundation, Inc.59 Temple Place - Suite 330,波士顿,MA 02111-1307,美国。

*/

包com.etoak.crawl.util;

导入org.mozilla.universalchardet.UniversalDetector;

导入java.io.UnsupportedEncodingException;

导入java.util.regex.Matcher;

导入java.util.regex.Pattern;

/**

* 字符集自动检测

* @作者胡

*/

公共类CharsetDetector {

//网页编码检测代码借用Nutch

私有静态最终int CHUNK_SIZE=2000;

私有静态模式metaPattern=Pattern.compile(

"]*http-equiv=("|")?内容类型("|")?[^]*)",

模式.CASE_INSENSITIVE);

私有静态模式charsetPattern=Pattern.compile(

"charset=\s*([a-z][_\-0-9a-z]*)", Pattern.CASE_INSENSITIVE);

私有静态模式charsetPatternHTML5=Pattern.compile(

"]*",

模式.CASE_INSENSITIVE);

//网页编码检测代码借用Nutch

私有静态字符串guessEncodingByNutch(byte[] content) {

int 长度=Math.min(content.length, CHUNK_SIZE);

字符串str="";

尝试{

str=new String(内容, "ascii");

} catch (UnsupportedEncodingException e) {

返回空值;

}

匹配器metaMatcher=metaPattern.matcher(str);

字符串编码=null;

if (metaMatcher.find()) {

匹配器charsetMatcher=charsetPattern.matcher(metaMatcher.group(1));

if (charsetMatcher.find()) {

编码=new String(charsetMatcher.group(1));

}

}

如果(编码==空){

metaMatcher=charsetPatternHTML5.matcher(str);

如果(metaMatcher.find()) {

编码=

new String(metaMatcher.group(1));             }         }         if (encoding == null) {             if (length >= 3 && content[0] == (byte) 0xEF                     && content[1] == (byte) 0xBB && content[2] == (byte) 0xBF) {                 encoding = "UTF-8";             } else if (length >= 2) {                 if (content[0] == (byte) 0xFF && content[1] == (byte) 0xFE) {                     encoding = "UTF-16LE";                 } else if (content[0] == (byte) 0xFE                         && content[1] == (byte) 0xFF) {                     encoding = "UTF-16BE";                 }             }         }         return encoding;     }     /**     * 根据字节数组,猜测可能的字符集,如果检测失败,返回utf-8     *     * @param bytes 待检测的字节数组     * @return 可能的字符集,如果检测失败,返回utf-8     */     public static String guessEncodingByMozilla(byte[] bytes) {         String DEFAULT_ENCODING = "UTF-8";         UniversalDetector detector = new UniversalDetector(null);         detector.handleData(bytes, 0, bytes.length);         detector.dataEnd();         String encoding = detector.getDetectedCharset();         detector.reset();         if (encoding == null) {             encoding = DEFAULT_ENCODING;         }         return encoding;     }     /**     * 根据字节数组,猜测可能的字符集,如果检测失败,返回utf-8     * @param content 待检测的字节数组     * @return 可能的字符集,如果检测失败,返回utf-8     */     public static String guessEncoding(byte[] content) {         String encoding;         try {             encoding = guessEncodingByNutch(content);         } catch (Exception ex) {             return guessEncodingByMozilla(content);         }         if (encoding == null) {             encoding = guessEncodingByMozilla(content);             return encoding;         } else {             return encoding;         }     } } FileTool .java本类主要是 下载那些已经访问过的文件 package com.etoak.crawl.util; import com.etoak.crawl.page.Page; import java.io.DataOutputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; /*  本类主要是 下载那些已经访问过的文件*/ public class FileTool {     private static String dirPath;     /**     * getMethod.getResponseHeader("Content-Type").getValue()     * 根据 URL 和网页类型生成需要保存的网页的文件名,去除 URL 中的非文件名字符     */     private static String getFileNameByUrl(String url, String contentType) {         //去除 http://         url = url.substring(7);         //text/html 类型         if (contentType.indexOf("html") != -1) {             url = url.replaceAll("[\?/:*|<>"]", "_") + ".html";             return url;         }         //如 application/pdf 类型         else {             return url.replaceAll("[\?/:*|<>"]", "_") + "." +                     contentType.substring(contentType.lastIndexOf("/") + 1);         }     }     /*     *  生成目录     * */     private static void mkdir() {         if (dirPath == null) {             dirPath = Class.class.getClass().getResource("/").getPath() + "temp\";         }         File fileDir = new File(dirPath);         if (!fileDir.exists()) {             fileDir.mkdir();         }     }     /**     * 保存网页字节数组到本地文件,filePath 为要保存的文件的相对地址     */     public static void saveToLocal(Page page) {         mkdir();         String fileName =  getFileNameByUrl(page.getUrl(), page.getContentType()) ;         String filePath = dirPath + fileName ;         byte[] data = page.getContent();         try {             //Files.lines(Paths.get("D:\jd.txt"), StandardCharsets.UTF_8).forEach(System.out::println);             DataOutputStream out = new DataOutputStream(new FileOutputStream(new File(filePath)));             for (int i = 0; i< data.length; i++) {                 out.write(data[i]);             }             out.flush();             out.close();             System.out.println("文件:"+ fileName + "已经被存储在"+ filePath  );         } catch (IOException e) {             e.printStackTrace();         }     } }

RegexRule.java类,正则 /* * Copyright (C) 2014 hu * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License * as published by the Free Software Foundation; either version 2 * of the License, or (at your option) any later version. * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the * GNU General Public License for more details. * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA. */ package com.etoak.crawl.util; import java.util.ArrayList; import java.util.regex.Pattern; /** * @author hu */ public class RegexRule {     public RegexRule(){     }     public RegexRule(String rule){         addRule(rule);     }     public RegexRule(ArrayListrules){         for (String rule : rules) {             addRule(rule);         }     }     public boolean isEmpty(){         return positive.isEmpty();     }     private ArrayListpositive = new ArrayList();     private ArrayListnegative = new ArrayList();     /**     * 添加一个正则规则 正则规则有两种,正正则和反正则     * URL符合正则规则需要满足下面条件: 1.至少能匹配一条正正则 2.不能和任何反正则匹配     * 正正则示例:+a.*c是一条正正则,正则的内容为a.*c,起始加号表示正正则     * 反正则示例:-a.*c时一条反正则,正则的内容为a.*c,起始减号表示反正则     * 如果一个规则的起始字符不为加号且不为减号,则该正则为正正则,正则的内容为自身     * 例如a.*c是一条正正则,正则的内容为a.*c     * @param rule 正则规则     * @return 自身     */     public RegexRule addRule(String rule) {         if (rule.length() == 0) {             return this;         }         char pn = rule.charAt(0);         String realrule = rule.substring(1);         if (pn == "+") {             addPositive(realrule);         } else if (pn == "-") {             addNegative(realrule);         } else {             addPositive(rule);         }         return this;     }     /**     * 添加一个正正则规则     * @param positiveregex     * @return 自身     */     public RegexRule addPositive(String positiveregex) {         positive.add(positiveregex);         return this;     }     /**     * 添加一个反正则规则     * @param negativeregex     * @return 自身     */     public RegexRule addNegative(String negativeregex) {         negative.add(negativeregex);         return this;     }     /**     * 判断输入字符串是否符合正则规则     * @param str 输入的字符串     * @return 输入字符串是否符合正则规则     */     public boolean satisfy(String str) {         int state = 0;         for (String nregex : negative) {             if (Pattern.matches(nregex, str)) {                 return false;             }         }         int count = 0;         for (String pregex : positive) {             if (Pattern.matches(pregex, str)) {                 count++;             }         }         if (count == 0) {             return false;         } else {             return true;         }     } } MyCrawler.java 爬取主类 package com.etoak.crawl.main; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.net.URLEncoder; import java.util.ArrayList; import java.util.List; import org.jsoup.select.Elements; import com.alibaba.fastjson.JSON; import com.etoak.crawl.link.LinkFilter; import com.etoak.crawl.link.Links; import com.etoak.crawl.page.Page; import com.etoak.crawl.page.PageParserTool; import com.etoak.crawl.page.RequestAndResponseTool; import com.etoak.crawl.util.FileTool; public class MyCrawler {     /**     * 使用种子初始化 URL 队列     *     * @param seeds 种子 URL     * @return     */     private void initCrawlerWithSeeds(String[] seeds) {         for (int i = 0; i< seeds.length; i++){             Links.addUnvisitedUrlQueue(seeds[i]);         }     }     /**     * 抓取过程     *     * @param seeds     * @return     */     public void crawling(String[] seeds , String name) {         //初始化 URL 队列         initCrawlerWithSeeds(seeds);         //定义过滤器,提取以 http://www.baidu.com 开头的链接         LinkFilter filter = new LinkFilter() {             public boolean accept(String url) {                 if (url.startsWith("https://www.jd.com"))                     return true;                 else                     return false;             }         };         //循环条件:待抓取的链接不空且抓取的网页不多于 1000         int m = 1;         for (int i = 0; i< m; i++) {                   //先从待访问的序列中取出第一个;                   String visitUrl = (String) Links.removeHeadOfUnVisitedUrlQueue();                   if (visitUrl == null){                       continue;                   }                   //根据URL得到page;                   Page page = RequestAndResponseTool.sendRequstAndGetResponse(visitUrl);                   //对page进行处理: 访问DOM的某个标签                   System.out.println(page);                   Elements es = PageParserTool.select(page,"img[src]");                   if(!es.isEmpty()){                       System.out.println("下面将打印所有img[src]标签: ");                       System.out.println(es);                   }                   //得到超链接                   ArrayList  links = PageParserTool.getAttrs(page,"img[src]","abs:src");                   m = links.size();                   try {       FileTool.saveToLocal(page,URLDecoder.decode(name, "utf-8"));       } catch (UnsupportedEncodingException e) {       // TODO Auto-generated catch block       e.printStackTrace();       }                   if(links!=null&&links.size()>0){                   Links.addUnvisitedUrlQueue(links.get(3));                   System.out.println("新增爬取路径: " + links);                   } }     }     //main 方法入口     public static void main(String[] args) {     String as ="["帆布鞋"]";     Listlist =JSON.parseArray(as);     /*JSONObject json = null; json = RedisUtils.getObject("JDClassCid3"); if(json!=null){ list = (List) json.get("list"); }else{ list = ShoppingGuideUtils.getClasss11(); Mapmap = new HashMap(); map.put("list", list); RedisUtils.setObjectMap("JDClassCid3", map, RedisUtils.EXRP_DAY); }*/     System.out.println(list);     if(list!=null&&list.size()>0){     String name= "";     for (int i = 0; i< list.size(); i++) {     MyCrawler crawler = new MyCrawler();     name = list.get(i).toString();     try { if(URLDecoder.decode(name, "utf-8").contains("二手")){//去除二手物品 continue; } } catch (UnsupportedEncodingException e2) { // TODO Auto-generated catch block e2.printStackTrace(); }     try {     name =URLEncoder.encode(name, "utf-8");     } catch (UnsupportedEncodingException e1) {     // TODO Auto-generated catch block     e1.printStackTrace();     }     crawler.crawling(new String[]{"https://so.m.jd.com/ware/search.action?keyword="+name+"&searchFrom=category&sf=1&as=1"},name);     try { System.out.println("总计"+list.size()+"个===当前分类"+URLDecoder.decode(name, "utf-8")+"爬到"+i+"个"); } catch (UnsupportedEncodingException e) { // TODO Auto-generated catch block e.printStackTrace(); } }     }     /*MyCrawler crawler = new MyCrawler();     String name= "ETC";     try { if(URLDecoder.decode(name, "utf-8").contains("二手")){ System.out.println("jies"); } } catch (UnsupportedEncodingException e2) { // TODO Auto-generated catch block e2.printStackTrace(); } try { name =URLEncoder.encode(name, "utf-8"); } catch (UnsupportedEncodingException e1) { // TODO Auto-generated catch block e1.printStackTrace(); } crawler.crawling(new String[]{"https://so.m.jd.com/ware/search.action?keyword="+name+"&searchFrom=category&sf=1&as=1"},name); */ } } 我选的根url是京东是搜索接口,取出来合适的图片,这里难点就在这,取出来html中的img集合,匹配度最高的,这得批跑抓取好几次,还有比较有争议的分类名称,比如“苹果”这种就得特殊处理

关于高效抓取商品分类图标:Java爬虫WebCollector与jsoup结合应用和的介绍到此就结束了,不知道你从中找到你需要的信息了吗 ?如果你还想了解更多这方面的信息,记得收藏关注本站。

用户评论

念旧是个瘾。

这听起来很有用!我现在要用Java开发网站数据分析项目,这个方法可以帮上很大的忙。

    有12位网友表示赞同!

柠栀

我之前看过Jsoup的使用,很强大!搭配WebCollector应该能更方便地爬取分类信息吧。

    有19位网友表示赞同!

肆忌

学习一下网页抓取的知识听起来很有趣哦~想在自己的项目里尝试一下这个方法。

    有6位网友表示赞同!

最迷人的危险

我想把商品分类图标收集下来做个数据可视化分析,这个方法挺合适的!

    有14位网友表示赞同!

断秋风

Java语言很棒,特别是WebCollector和Jsoup这类的工具,开发效率真的很高啊。

    有18位网友表示赞同!

娇眉恨

有没有什么教程可以介绍如何使用这两个库的?我也想学习一下网页抓取的相关知识。

    有19位网友表示赞同!

蝶恋花╮

我之前尝试过爬虫,感觉很多时候会遇到网站的防爬机制,这个方法好用吗?

    有20位网友表示赞同!

掉眼泪

这篇文章应该能帮我解决我在项目中遇到的分类图标获取的问题,期待能够顺利完成。

    有17位网友表示赞同!

淡写薰衣草的香

学习一下网页抓取技巧,可以帮助我也在数据分析方面更进一步啊!

    有7位网友表示赞同!

珠穆郎马疯@

想利用爬取的数据来做一些有趣的应用开发,这个方法看起来很有潜力啊!

    有17位网友表示赞同!

暮染轻纱

现在很多电商网站的分类图标都设计得很有特点,能收集起来做个研究还挺有意思的。

    有19位网友表示赞同!

白恍

我想用Python爬虫,不过Java也是很好的选择,看来我会学习学习WebCollector和Jsoup的使用了。

    有12位网友表示赞同!

相知相惜

这篇文章解决了我的知识盲点,以前对这方面不太了解,现在终于可以开始尝试练习了!

    有6位网友表示赞同!

关于道别

对于数据分析来说,收集分类信息确实很重要,这个方法能够帮我节省很多时间和精力啊。

    有13位网友表示赞同!

安好如初

我很期待看到作者的后续文章,希望能详细讲解使用WebCollector和Jsoup抓取商品分类图标的过程。

    有16位网友表示赞同!

╯念抹浅笑

学习新技能永远不会错!看看别人是怎么用Java爬虫进行数据获取的,也许会有新的灵感。

    有17位网友表示赞同!

剑已封鞘

这篇文章提供的知识点非常实用,我应该可以把它应用到我的项目中改进代码效率。

    有6位网友表示赞同!

志平

我对网页抓取技术一直很感兴趣,这个帖子让我更想去深入学习了!&nb

    有6位网友表示赞同!

见朕骑妓的时刻

我想要收集一些商品销售数据,不知道这个方法能不能用来获取商品的详细信息。

    有10位网友表示赞同!

【高效抓取商品分类图标:Java爬虫WebCollector与jsoup结合应用】相关文章:

1.蛤蟆讨媳妇【哈尼族民间故事】

2.米颠拜石

3.王羲之临池学书

4.清代敢于创新的“浓墨宰相”——刘墉

5.“巧取豪夺”的由来--米芾逸事

6.荒唐洁癖 惜砚如身(米芾逸事)

7.拜石为兄--米芾逸事

8.郑板桥轶事十则

9.王献之被公主抢亲后的悲惨人生

10.史上真实张三丰:在棺材中竟神奇复活

上一篇:揭秘返利软件盈利模式:返利网与平台赚钱之道 下一篇:真实急招!半天班3500元,揭秘店员赚钱之道