搜尋
首頁web前端js教程NodeJS爬蟲詳解

NodeJS爬蟲詳解

Feb 22, 2018 am 11:31 AM
javascriptnodejs爬蟲

一、爬蟲流程

我們最終的目標是實現爬取立刻理財每日的銷售額,並知道賣了哪些產品,每個產品又被哪些用戶在什麼時間點買的。首先,介紹下爬蟲爬取的主要步驟:

1. 結構分析

我們要爬取頁面的數據,第一步當然是要先分析清楚頁面結構,要爬哪些頁面,頁面的結構是怎樣的,需不需要登入;有沒有ajax接口,返回什麼樣的資料等。

2. 資料抓取

分析清楚要爬取哪些頁面和ajax,就要去抓取資料了。如今的網頁的數據,大體分為同步頁面和ajax介面。同步頁面資料的抓取就需要我們先分析網頁的結構,python抓取資料一般是透過正規表示式匹配來取得需要的資料;node有一個cheerio的工具,可以將取得的頁面內容轉換成jquery對象,然後就可以用jquery強大的dom API來取得節點相關數據, 其實大家看源碼,這些API本質也就是正規匹配。 ajax介面資料一般都是json格式的,處理起來還是比較簡單的。

3. 資料儲存

抓取的資料後,會做簡單的篩選,然後將所需的資料先保存起來,以便後續的分析處理。當然我們可以用MySQL和Mongodb等資料庫儲存資料。這裡,我們為了方便,直接採用文件儲存。

4. 資料分析

因為我們最終是要展示資料的,所以我們要將原始的資料依照一定維度去處理分析,然後回傳給客戶端。這個過程可以在儲存的時候去處理,也可以在展示的時候,前端發送請求,後台取出儲存的資料再處理。這個看我們要怎麼展示數據了。

5. 結果展示

做了這麼多工作,一點展示輸出都沒有,怎麼甘心呢?這又回到了我們的老本行,前端展示頁面大家應該都很熟悉了。將數據展示出來才更直觀,方便我們分析統計。

二、爬蟲常用函式庫介紹

1. Superagent

Superagent是個輕量的的http方面的函式庫,是nodejs裡一個非常方便的客戶端請求代理模組,當我們需要進行get、post、head等網路請求時,試試它。

2. Cheerio

Cheerio大家可以理解成一個 Node.js 版的 jquery,用來從網頁中以 css selector 取數據,使用方式跟 jquery 一模一樣。

3. Async

Async是一個流程控制工具包,提供了直接而強大的非同步功能mapLimit(arr, limit, iterator, callback),我們主要用到這個方法,大家可以去看看官網的API。

4. arr-del

arr-del是我自己寫的一個刪除陣列元素方法的工具。可以透過傳入待刪除數組元素index組成的數組進行一次刪除。

5. arr-sort

arr-sort是我自己寫的一個陣列排序方法的工具。可以根據一個或多個屬性進行排序,支援嵌套的屬性。而且可以再在每個條件中指定排序的方向,並支援傳入比較函數。

三、頁面結構分析

先屢一下我們爬取的想法。立刻理財線上的產品主要是定期和立馬金庫(最新上線的光大銀行理財產品因為手續比較麻煩,而且起投金額高,基本沒人買,這裡不統計)。定期我們可以爬取理財頁的ajax介面:https://www.lmlc.com/web/product/product_list?pageSize=10&pageNo=1&type=0。 (update: 定期近期沒貨,可能看不到資料)資料如下圖所示:

NodeJS爬蟲詳解

#這裡包含了所有線上正在銷售的定期產品,ajax資料只有產品本身相關的訊息,例如產品id、籌集金額、當前銷售額、年化收益率、投資天數等,並沒有產品被哪些用戶購買的資訊。所以我們需要帶著id參數去它的產品詳情頁爬取,例如立刻聚財-12月期HLB01239511。詳情頁有一欄投資記錄,裡邊包含了我們需要的信息,如下圖所示:

NodeJS爬蟲詳解

#但是,詳情頁需要我們在登入的狀態下才可以查看,這就需要我們帶著cookie去訪問,而且cookie是有有效期限制的,如何保持我們cookie一直在登錄態?請看後文。

其實立刻金庫也有類似的ajax介面:https://www.lmlc.com/web/product/product_list?pageSize=10&pageNo=1&type=1,但裡面的相關資料都是寫死的,沒有意義。而且金庫的詳情頁面也沒有投資記錄資訊。這需要我們爬取一開始說的首頁的ajax介面:https://www.lmlc.com/s/web/home/user_buying。但後來才發現這個介面是三分鐘更新一次,就是說後台每隔三分鐘就向伺服器請求一次資料。而一次是10條數據,所以如果在三分鐘內,購買產品的記錄數超過10條,數據就會有遺漏。這是沒有辦法的,所以立刻金庫的統計數據會比真實的偏少。

四、爬蟲程式碼分析

1. 取得登入cookie

因為產品詳情頁需要登入,所以我們要先拿到登入的cookie才行。 getCookie方法如下:

function getCookie() {
    superagent.post('https://www.lmlc.com/user/s/web/logon')
        .type('form')
        .send({
            phone: phone,
            password: password,
            productCode: "LMLC",
            origin: "PC"
        })
        .end(function(err, res) {
            if (err) {
                handleErr(err.message);
                return;
            }
            cookie = res.header['set-cookie']; //从response中得到cookie
            emitter.emit("setCookeie");
        })
}

phone和password參數是從命令列傳進來的,就是立刻理財用手機號碼登入的帳號和密碼。我們用superagent去模擬請求立刻理財登入介面:https://www.lmlc.com/user/s/web/logon。傳入對應的參數,在回呼中,我們拿到header的set-cookie訊息,並發出一個setCookeie事件。因為我們設定了監聽事件:emitter.on("setCookie", requestData),所以一旦取得cookie,我們就會去執行requestData方法。

2. 理財頁ajax的爬取

requestData方法的程式碼如下:

function requestData() {
    superagent.get('https://www.lmlc.com/web/product/product_list?pageSize=100&pageNo=1&type=0')
    .end(function(err,pres){
        // 常规的错误处理
        if (err) {
            handleErr(err.message);
            return;
        }
        // 在这里清空数据,避免一个文件被同时写入
        if(clearProd){
            fs.writeFileSync('data/prod.json', JSON.stringify([]));
            clearProd = false;
        }
        let addData = JSON.parse(pres.text).data;
        let formatedAddData = formatData(addData.result);
        let pageUrls = [];
        if(addData.totalPage > 1){
            handleErr('产品个数超过100个!');
            return;
        }
        for(let i=0,len=addData.result.length; i<len><p>程式碼很長,getDetailData函數程式碼後面分析。 </p>
<p>請求的ajax接口是個分頁接口,因為一般在售的總產品數不會超過10條,我們這裡設置參數pageSize為100,這樣就可以一次獲取所有產品。 </p>
<p>clearProd是全域reset訊號,每天0點整的時候,會清空prod(定期產品)和user(首頁使用者)資料。 </p>
<p>因為有時候產品較少會採用搶購的方式,例如每天10點,這樣在每天10點的時候資料會更新很快,我們必須要增加爬取的頻次,以防遺失資料。所以針對預售產品即buyStartTime大於目前時間,我們要記錄下,並設定計時器,當開售時,調整爬取頻次為1次/秒,見setPreId方法。 </p>
<p>如果沒有正在販售的產品,即pageUrls為空,我們將爬取的頻次設定為最大32s。 </p>
<p>requestData函數的這部分程式碼主要記錄下是否有新產品,如果有的話,新建一個對象,記錄產品信息,push到prod數組裡。 prod.json資料結構如下:</p>
<pre class="brush:php;toolbar:false">[{
  "productName": "立马聚财-12月期HLB01230901",
  "financeTotalAmount": 1000000,
  "productId": "201801151830PD84123120",
  "yearReturnRate": 6.4,
  "investementDays": 364,
  "interestStartTime": "2018年01月23日",
  "interestEndTime": "2019年01月22日",
  "getDataTime": 1516118401299,
  "alreadyBuyAmount": 875000,
  "records": [
  {
    "username": "刘**",
    "buyTime": 1516117093472,
    "buyAmount": 30000,
    "uniqueId": "刘**151611709347230,000元"
  },
  {
    "username": "刘**",
    "buyTime": 1516116780799,
    "buyAmount": 50000,
    "uniqueId": "刘**151611678079950,000元"
  }]
}]

是一個物件數組,每個物件表示一個新產品,records屬性記錄著售賣資訊。

3. 產品詳情頁的爬取

我們再看下getDetailData的程式碼:

function getDetailData(){
    // 请求用户信息接口,来判断登录是否还有效,在产品详情页判断麻烦还要造成五次登录请求
    superagent
        .post('https://www.lmlc.com/s/web/m/user_info')
        .set('Cookie', cookie)
        .end(function(err,pres){
        // 常规的错误处理
        if (err) {
            handleErr(err.message);
            return;
        }
        let retcode = JSON.parse(pres.text).retcode;
        if(retcode === 410){
            handleErr('登陆cookie已失效,尝试重新登陆...');
            getCookie();
            return;
        }
        var reptileLink = function(url,callback){
            // 如果爬取页面有限制爬取次数,这里可设置延迟
            console.log( '正在爬取产品详情页面:' + url);
            superagent
                .get(url)
                .set('Cookie', cookie)
                .end(function(err,pres){
                    // 常规的错误处理
                    if (err) {
                        handleErr(err.message);
                        return;
                    }
                    var $ = cheerio.load(pres.text);
                    var records = [];
                    var $table = $('.buy-records table');
                    if(!$table.length){
                        $table = $('.tabcontent table');
                    }
                    var $tr = $table.find('tr').slice(1);
                    $tr.each(function(){
                        records.push({
                            username: $('td', $(this)).eq(0).text(),
                            buyTime: parseInt($('td', $(this)).eq(1).attr('data-time').replace(/,/g, '')),
                            buyAmount: parseFloat($('td', $(this)).eq(2).text().replace(/,/g, '')),
                            uniqueId: $('td', $(this)).eq(0).text() + $('td', $(this)).eq(1).attr('data-time').replace(/,/g, '') + $('td', $(this)).eq(2).text()
                        })
                    });
                    callback(null, {
                        productId: url.split('?id=')[1],
                        records: records
                    });
                });
        };
        async.mapLimit(pageUrls, 10 ,function (url, callback) {
          reptileLink(url, callback);
        }, function (err,result) {
            let time = (new Date()).format("yyyy-MM-dd hh:mm:ss");
            console.log(`所有产品详情页爬取完毕,时间:${time}`.info);
            let oldRecord = JSON.parse(fs.readFileSync('data/prod.json', 'utf-8'));
            let counts = [];
            for(let i=0,len=result.length; i<len>=0 && maxNum =8 && maxNum = 60*1000)){
                    handleErr('部分数据可能丢失!');
                }
                if(delay = 32*1000){
                    delay = 32*1000;
                }
                return delay
            }
            if(oldDelay != delay){
                clearInterval(timer);
                timer = setInterval(function(){
                    requestData();
                }, delay);
            }
            fs.writeFileSync('data/prod.json', JSON.stringify(oldRecord));
        })
    });
}</len>

我們先去請求用戶資訊接口,來判斷登入是否還有效,因為在產品詳情頁面判斷麻煩還要造成五次登入要求。帶cookie請求很簡單,在post後面set下我們之前得到的cookie即可:.set('Cookie', cookie)。如果後台回傳的retcode為410表示登入的cookie已失效,則需要重新執行getCookie()。這樣就能保證爬蟲一直在登入狀態。

async的mapLimit方法,會將pageUrls進行並發請求,一次並發量為10。對於每個pageUrl會執行reptileLink方法。等所有的非同步執行完畢後,再執行回呼函數。回呼函數的result參數是每個reptileLink函數傳回資料組成的陣列。

reptileLink函數是獲取產品詳情頁的投資記錄列表信息,uniqueId是由已知的username、buyTime、buyAmount參數組成的字符串,用來排重的。

async的回呼主要是將最新的投資記錄資訊寫入對應的產品物件裡,同時產生了counts陣列。 counts數組是每個產品這次爬取新增的售賣記錄個數字組成的數組,和delay一起傳入getNewDelay函數。 getNewDelay動態調節爬取頻次,counts是調節delay的唯一依據。 delay過大可能產生資料遺失,過小會增加伺服器負擔,可能會被管理員封ip。這裡設定delay最大值為32,最小值為1。

4. 首頁用戶ajax爬取

先上程式碼:

function requestData1() {
    superagent.get(ajaxUrl1)
    .end(function(err,pres){
        // 常规的错误处理
        if (err) {
            handleErr(err.message);
            return;
        }
        let newData = JSON.parse(pres.text).data;
        let formatNewData = formatData1(newData);
        // 在这里清空数据,避免一个文件被同时写入
        if(clearUser){
            fs.writeFileSync('data/user.json', '');
            clearUser = false;
        }
        let data = fs.readFileSync('data/user.json', 'utf-8');
        if(!data){
            fs.writeFileSync('data/user.json', JSON.stringify(formatNewData));
            let time = (new Date()).format("yyyy-MM-dd hh:mm:ss");
            console.log((`首页用户购买ajax爬取完毕,时间:${time}`).silly);
        }else{
            let oldData = JSON.parse(data);
            let addData = [];
            // 排重算法,如果uniqueId不一样那肯定是新生成的,否则看时间差如果是0(三分钟内请求多次)或者三分钟则是旧数据
            for(let i=0, len=formatNewData.length; i<len><p>user.js的爬取和prod.js類似,這裡主要想說如何排重的。 user.json資料格式如下:</p>
<pre class="brush:php;toolbar:false">[
{
  "payAmount": 5067.31,
  "productId": "jsfund",
  "productName": "立马金库",
  "productType": 6,
  "time": 1548489,
  "username": "郑**",
  "buyTime": 1516118397758,
  "uniqueId": "5067.31jsfund郑**"
}, {
  "payAmount": 30000,
  "productId": "201801151830PD84123120",
  "productName": "立马聚财-12月期HLB01230901",
  "productType": 0,
  "time": 1306573,
  "username": "刘**",
  "buyTime": 1516117199684,
  "uniqueId": "30000201801151830PD84123120刘**"
}]

和产品详情页类似,我们也生成一个uniqueId参数用来排除,它是payAmount、productId、username参数的拼成的字符串。如果uniqueId不一样,那肯定是一条新的记录。如果相同那一定是一条新记录吗?答案是否定的。因为这个接口数据是三分钟更新一次,而且给出的时间是相对时间,即数据更新时的时间减去购买的时间。所以每次更新后,即使是同一条记录,时间也会不一样。那如何排重呢?其实很简单,如果uniqueId一样,我们就判断这个buyTime,如果buyTime的差正好接近180s,那么几乎可以肯定是旧数据。如果同一个人正好在三分钟后购买同一个产品相同的金额那我也没辙了,哈哈。

5. 零点整合数据

每天零点我们需要整理user.json和prod.json数据,生成最终的数据。代码:

let globalTimer = setInterval(function(){
    let nowTime = +new Date();
    let nowStr = (new Date()).format("hh:mm:ss");
    let max = nowTime;
    let min = nowTime - 24*60*60*1000;
    // 每天00:00分的时候写入当天的数据
    if(nowStr === "00:00:00"){
        // 先保存数据
        let prod = JSON.parse(fs.readFileSync('data/prod.json', 'utf-8'));
        let user = JSON.parse(fs.readFileSync('data/user.json', 'utf-8'));
        let lmlc = JSON.parse(JSON.stringify(prod));
        // 清空缓存数据
        clearProd = true;
        clearUser = true;
        // 不足一天的不统计
        // if(nowTime - initialTime = max){
                    delArr1.push(j);
                }
            }
            sort.delArrByIndex(lmlc[i].records, delArr1);
        }
        // 删掉prod.records为空的数据
        let delArr2 = [];
        for(let i=0, len=lmlc.length; i<len>= min && user[i].buyTime <p>globalTimer是个全局定时器,每隔1s执行一次,当时间为<code>00:00:00</code>时,clearProd和clearUser全局参数为<code>true</code>,这样在下次爬取过程时会清空user.json和prod.json文件。没有同步清空是因为防止多处同时修改同一文件报错。取出user.json里的所有金库记录,获取当天金库相关信息,生成一条立马金库的prod信息并unshift进prod.json里。删除一些无用属性,排序数组最终生成带有当天时间戳的json文件,如:20180101.json。</p>
<h2 id="五-前端展示">五、前端展示</h2>
<h3 id="整体思路">1、整体思路</h3>
<p>前端总共就两个页面,首页和详情页,首页主要展示实时销售额、某一时间段内的销售情况、具体某天的销售情况。详情页展示某天的具体某一产品销售情况。页面有两个入口,而且比较简单,这里我们采用gulp来打包压缩构建前端工程。后台用express搭建的,匹配到路由,从data文件夹里取到数据再分析处理再返回给前端。</p>
<h3 id="前端用到的组件介绍">2、前端用到的组件介绍</h3>
<ul class=" list-paddingleft-2"><li><p>Echarts</p></li></ul>
<p>Echarts是一个绘图利器,百度公司不可多得的良心之作。能方便的绘制各种图形,官网已经更新到4.0了,功能更加强大。我们这里主要用到的是直方图。</p>
<ul class=" list-paddingleft-2"><li><p>DataTables</p></li></ul>
<p>Datatables是一款jquery表格插件。它是一个高度灵活的工具,可以将任何HTML表格添加高级的交互功能。功能非常强大,有丰富的API,大家可以去官网学习。</p>
<ul class=" list-paddingleft-2"><li><p>Datepicker</p></li></ul>
<p>Datepicker是一款基于jquery的日期选择器,需要的功能基本都有,主要样式比较好看,比jqueryUI官网的Datepicker好看太多。</p>
<h3 id="gulp配置">3、gulp配置</h3>
<p>gulp配置比较简单,代码如下:</p>
<pre class="brush:php;toolbar:false">var gulp = require('gulp');
var uglify = require("gulp-uglify");
var less = require("gulp-less");
var minifyCss = require("gulp-minify-css");
var livereload = require('gulp-livereload');
var connect = require('gulp-connect');
var minimist = require('minimist');
var babel = require('gulp-babel');

var knownOptions = {
  string: 'env',
  default: { env: process.env.NODE_ENV || 'production' }
};

var options = minimist(process.argv.slice(2), knownOptions);

// js文件压缩
gulp.task('minify-js', function() {
    gulp.src('src/js/*.js')
        .pipe(babel({
          presets: ['es2015']
        }))
        .pipe(uglify())
        .pipe(gulp.dest('dist/'));
});

// js移动文件
gulp.task('move-js', function() {
    gulp.src('src/js/*.js')
        .pipe(babel({
          presets: ['es2015']
        }))
        .pipe(gulp.dest('dist/'))
        .pipe(connect.reload());
});

// less编译
gulp.task('compile-less', function() {
    gulp.src('src/css/*.less')
        .pipe(less())
        .pipe(gulp.dest('dist/'))
        .pipe(connect.reload());
});

// less文件编译压缩
gulp.task('compile-minify-css', function() {
    gulp.src('src/css/*.less')
        .pipe(less())
        .pipe(minifyCss())
        .pipe(gulp.dest('dist/'));
});

// html页面自动刷新
gulp.task('html', function () {
  gulp.src('views/*.html')
    .pipe(connect.reload());
});

// 页面自动刷新启动
gulp.task('connect', function() {
    connect.server({
        livereload: true
    });
});

// 监测文件的改动
gulp.task('watch', function() {
    gulp.watch('src/css/*.less', ['compile-less']);
    gulp.watch('src/js/*.js', ['move-js']);
    gulp.watch('views/*.html', ['html']);
});

// 激活浏览器livereload友好提示
gulp.task('tip', function() {
    console.log('\n\n');
});

if (options.env === 'development') {
    gulp.task('default', ['move-js', 'compile-less', 'connect', 'watch', 'tip']);
}else{
    gulp.task('default', ['minify-js', 'compile-minify-css']);
}

开发和生产环境都是将文件打包到dist目录。不同的是:开发环境只是编译es6和less文件;生产环境会再压缩混淆。支持livereload插件,在开发环境下,文件改动会自动刷新页面。

相关推荐:

NodeJS爬虫实例之糗事百科_node.js

nodejs爬虫抓取数据乱码问题总结_node.js

nodejs爬虫抓取数据之编码问题_node.js


以上是NodeJS爬蟲詳解的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn
JavaScript在行動中:現實世界中的示例和項目JavaScript在行動中:現實世界中的示例和項目Apr 19, 2025 am 12:13 AM

JavaScript在現實世界中的應用包括前端和後端開發。 1)通過構建TODO列表應用展示前端應用,涉及DOM操作和事件處理。 2)通過Node.js和Express構建RESTfulAPI展示後端應用。

JavaScript和Web:核心功能和用例JavaScript和Web:核心功能和用例Apr 18, 2025 am 12:19 AM

JavaScript在Web開發中的主要用途包括客戶端交互、表單驗證和異步通信。 1)通過DOM操作實現動態內容更新和用戶交互;2)在用戶提交數據前進行客戶端驗證,提高用戶體驗;3)通過AJAX技術實現與服務器的無刷新通信。

了解JavaScript引擎:實施詳細信息了解JavaScript引擎:實施詳細信息Apr 17, 2025 am 12:05 AM

理解JavaScript引擎內部工作原理對開發者重要,因為它能幫助編寫更高效的代碼並理解性能瓶頸和優化策略。 1)引擎的工作流程包括解析、編譯和執行三個階段;2)執行過程中,引擎會進行動態優化,如內聯緩存和隱藏類;3)最佳實踐包括避免全局變量、優化循環、使用const和let,以及避免過度使用閉包。

Python vs. JavaScript:學習曲線和易用性Python vs. JavaScript:學習曲線和易用性Apr 16, 2025 am 12:12 AM

Python更適合初學者,學習曲線平緩,語法簡潔;JavaScript適合前端開發,學習曲線較陡,語法靈活。 1.Python語法直觀,適用於數據科學和後端開發。 2.JavaScript靈活,廣泛用於前端和服務器端編程。

Python vs. JavaScript:社區,圖書館和資源Python vs. JavaScript:社區,圖書館和資源Apr 15, 2025 am 12:16 AM

Python和JavaScript在社區、庫和資源方面的對比各有優劣。 1)Python社區友好,適合初學者,但前端開發資源不如JavaScript豐富。 2)Python在數據科學和機器學習庫方面強大,JavaScript則在前端開發庫和框架上更勝一籌。 3)兩者的學習資源都豐富,但Python適合從官方文檔開始,JavaScript則以MDNWebDocs為佳。選擇應基於項目需求和個人興趣。

從C/C到JavaScript:所有工作方式從C/C到JavaScript:所有工作方式Apr 14, 2025 am 12:05 AM

從C/C 轉向JavaScript需要適應動態類型、垃圾回收和異步編程等特點。 1)C/C 是靜態類型語言,需手動管理內存,而JavaScript是動態類型,垃圾回收自動處理。 2)C/C 需編譯成機器碼,JavaScript則為解釋型語言。 3)JavaScript引入閉包、原型鍊和Promise等概念,增強了靈活性和異步編程能力。

JavaScript引擎:比較實施JavaScript引擎:比較實施Apr 13, 2025 am 12:05 AM

不同JavaScript引擎在解析和執行JavaScript代碼時,效果會有所不同,因為每個引擎的實現原理和優化策略各有差異。 1.詞法分析:將源碼轉換為詞法單元。 2.語法分析:生成抽象語法樹。 3.優化和編譯:通過JIT編譯器生成機器碼。 4.執行:運行機器碼。 V8引擎通過即時編譯和隱藏類優化,SpiderMonkey使用類型推斷系統,導致在相同代碼上的性能表現不同。

超越瀏覽器:現實世界中的JavaScript超越瀏覽器:現實世界中的JavaScriptApr 12, 2025 am 12:06 AM

JavaScript在現實世界中的應用包括服務器端編程、移動應用開發和物聯網控制:1.通過Node.js實現服務器端編程,適用於高並發請求處理。 2.通過ReactNative進行移動應用開發,支持跨平台部署。 3.通過Johnny-Five庫用於物聯網設備控制,適用於硬件交互。

See all articles

熱AI工具

Undresser.AI Undress

Undresser.AI Undress

人工智慧驅動的應用程序,用於創建逼真的裸體照片

AI Clothes Remover

AI Clothes Remover

用於從照片中去除衣服的線上人工智慧工具。

Undress AI Tool

Undress AI Tool

免費脫衣圖片

Clothoff.io

Clothoff.io

AI脫衣器

AI Hentai Generator

AI Hentai Generator

免費產生 AI 無盡。

熱工具

記事本++7.3.1

記事本++7.3.1

好用且免費的程式碼編輯器

SublimeText3 Mac版

SublimeText3 Mac版

神級程式碼編輯軟體(SublimeText3)

Dreamweaver Mac版

Dreamweaver Mac版

視覺化網頁開發工具

WebStorm Mac版

WebStorm Mac版

好用的JavaScript開發工具

禪工作室 13.0.1

禪工作室 13.0.1

強大的PHP整合開發環境