语音识别(html5+nodejs)

语音识别

赘述:

  • 这里提到的两种方法都依赖于浏览器来实现语音识别,然后进行录音。
    [En]

    both methods mentioned here rely on the browser to allow voice recognition and then record.*

  • 继而将语音流传给后端、调取第三方apk进行语音识别

关注点:

  • 使用阿里云 – 指定识别16k的wav后缀名的音频文件
  • 因先学习阿里云- 此时已get到转换音频文件的js方法
  • so.未关注百度云、但是转换之后的音频流是肯定会被识别的

基本步骤:

  • html中引入audio标签为后期传入音频流使用
  • 两个按钮:开始、停止(播放)
  • 调用recorder.js对音频文件进行处理
  • 传给node服务器,调取api接口进行语音识别

重要步骤

  • 在recoder.js中基本封装对音频的转换方法,不需要我们过多关注
  • 重点关注音频流转换时如何将音频流传输到后台。
    [En]

    focus on how to transmit the audio stream to the backend when the audio stream is converted.*

  • (依靠a标签的属性)将识别好的音频流转换成文件下载到本地,使用node服务读取本地文件
  • 直接将音频流传递给后台,使用formdata传输的形式,将其传递给node服务器

源码链接:

使用前端html+nodejs+阿里云语音识别apk

阿里云使用axios发送请求的方式,进行前端与nodejs交互

html
//audio标签
<audio controls autoplay></audio>
//&#x53CC;&#x6309;&#x94AE;
<input onclick="startRecording()" type="button" value="&#x5F55;&#x97F3;">
<input onclick="playRecording()" type="button" value="&#x64AD;&#x653E;">
js
  • 本地js
var recorder;
var audio = document.querySelector('audio');
//&#x5F00;&#x59CB;&#x5F55;&#x97F3;
function startRecording() {
    HZRecorder.get(function (rec) {
        recorder = rec;
        recorder.start();
    });
}
//&#x505C;&#x6B62;&#x5F55;&#x97F3;
 function playRecording() {
    recorder.play(audio);
 }

 //&#x8BF7;&#x6C42;&#x83B7;&#x53D6;&#x540E;&#x53F0;&#x8FD4;&#x56DE;&#x4FE1;&#x606F;
 $('.audio').click(function(){
     $.post('http://localhost:9999/getAudioInfo',{} ,function(data){
         console.log(data)
     })
 })
  • recoder.js

    在回放方法中将识别的音频流下载下来,后缀名为.wav,并将其保存到本地

(function (window) {
    //&#x517C;&#x5BB9;
    window.URL = window.URL || window.webkitURL;
    navigator.getUserMedia = navigator.getUserMedia ||                          navigator.webkitGetUserMedia || navigator.mozGetUserMedia ||                navigator.msGetUserMedia;

    var HZRecorder = function (stream, config) {
        config = config || {};
         //&#x91C7;&#x6837;&#x6570;&#x4F4D; 8, 16
        config.sampleBits = config.sampleBits || 16;
         //&#x91C7;&#x6837;&#x7387;(1/6 44100)
        config.sampleRate = config.sampleRate || (16000);

        var context = new AudioContext();
        var audioInput = context.createMediaStreamSource(stream);
        var recorder = context.createScriptProcessor(4096, 1, 1);

        var audioData = {
            size: 0          //&#x5F55;&#x97F3;&#x6587;&#x4EF6;&#x957F;&#x5EA6;
            , buffer: []     //&#x5F55;&#x97F3;&#x7F13;&#x5B58;
            , inputSampleRate: context.sampleRate    //&#x8F93;&#x5165;&#x91C7;&#x6837;&#x7387;
            , inputSampleBits: 16       //&#x8F93;&#x5165;&#x91C7;&#x6837;&#x6570;&#x4F4D; 8, 16
            , outputSampleRate: config.sampleRate    //&#x8F93;&#x51FA;&#x91C7;&#x6837;&#x7387;
            , oututSampleBits: config.sampleBits       //&#x8F93;&#x51FA;&#x91C7;&#x6837;&#x6570;&#x4F4D; 8, 16
            , input: function (data) {
                console.log(data)
                this.buffer.push(new Float32Array(data));
                this.size += data.length;
            }
            , compress: function () { //&#x5408;&#x5E76;&#x538B;&#x7F29;
                //&#x5408;&#x5E76;
                var data = new Float32Array(this.size);
                var offset = 0;
                for (var i = 0; i < this.buffer.length; i++) {
                    data.set(this.buffer[i], offset);
                    offset += this.buffer[i].length;
                }
                //&#x538B;&#x7F29;
                var compression = parseInt(this.inputSampleRate / this.outputSampleRate);
                var length = data.length / compression;
                var result = new Float32Array(length);
                var index = 0, j = 0;
                while (index < length) {
                    result[index] = data[j];
                    j += compression;
                    index++;
                }
                return result;
            }
            , encodeWAV: function () {
                var sampleRate = Math.min(this.inputSampleRate, this.outputSampleRate);
                var sampleBits = Math.min(this.inputSampleBits, this.oututSampleBits);
                var bytes = this.compress();
                var dataLength = bytes.length * (sampleBits / 8);
                var buffer = new ArrayBuffer(44 + dataLength);
                var data = new DataView(buffer);

                var channelCount = 1;//&#x5355;&#x58F0;&#x9053;
                var offset = 0;

                var writeString = function (str) {
                    for (var i = 0; i < str.length; i++) {
                        data.setUint8(offset + i, str.charCodeAt(i));
                    }
                }

                // &#x8D44;&#x6E90;&#x4EA4;&#x6362;&#x6587;&#x4EF6;&#x6807;&#x8BC6;&#x7B26;
                writeString('RIFF'); offset += 4;
                // &#x4E0B;&#x4E2A;&#x5730;&#x5740;&#x5F00;&#x59CB;&#x5230;&#x6587;&#x4EF6;&#x5C3E;&#x603B;&#x5B57;&#x8282;&#x6570;,&#x5373;&#x6587;&#x4EF6;&#x5927;&#x5C0F;-8
                data.setUint32(offset, 36 + dataLength, true); offset += 4;
                // WAV&#x6587;&#x4EF6;&#x6807;&#x5FD7;
                writeString('WAVE'); offset += 4;
                // &#x6CE2;&#x5F62;&#x683C;&#x5F0F;&#x6807;&#x5FD7;
                writeString('fmt '); offset += 4;
                // &#x8FC7;&#x6EE4;&#x5B57;&#x8282;,&#x4E00;&#x822C;&#x4E3A; 0x10 = 16
                data.setUint32(offset, 16, true); offset += 4;
                // &#x683C;&#x5F0F;&#x7C7B;&#x522B; (PCM&#x5F62;&#x5F0F;&#x91C7;&#x6837;&#x6570;&#x636E;)
                data.setUint16(offset, 1, true); offset += 2;
                // &#x901A;&#x9053;&#x6570;
                data.setUint16(offset, channelCount, true); offset += 2;
                // &#x91C7;&#x6837;&#x7387;,&#x6BCF;&#x79D2;&#x6837;&#x672C;&#x6570;,&#x8868;&#x793A;&#x6BCF;&#x4E2A;&#x901A;&#x9053;&#x7684;&#x64AD;&#x653E;&#x901F;&#x5EA6;
                data.setUint32(offset, sampleRate, true); offset += 4;
                // &#x6CE2;&#x5F62;&#x6570;&#x636E;&#x4F20;&#x8F93;&#x7387; (&#x6BCF;&#x79D2;&#x5E73;&#x5747;&#x5B57;&#x8282;&#x6570;) &#x5355;&#x58F0;&#x9053;&#xD7;&#x6BCF;&#x79D2;&#x6570;&#x636E;&#x4F4D;&#x6570;&#xD7;&#x6BCF;&#x6837;&#x672C;&#x6570;&#x636E;&#x4F4D;/8
                data.setUint32(offset, channelCount * sampleRate * (sampleBits / 8), true); offset += 4;
                // &#x5FEB;&#x6570;&#x636E;&#x8C03;&#x6574;&#x6570; &#x91C7;&#x6837;&#x4E00;&#x6B21;&#x5360;&#x7528;&#x5B57;&#x8282;&#x6570; &#x5355;&#x58F0;&#x9053;&#xD7;&#x6BCF;&#x6837;&#x672C;&#x7684;&#x6570;&#x636E;&#x4F4D;&#x6570;/8
                data.setUint16(offset, channelCount * (sampleBits / 8), true); offset += 2;
                // &#x6BCF;&#x6837;&#x672C;&#x6570;&#x636E;&#x4F4D;&#x6570;
                data.setUint16(offset, sampleBits, true); offset += 2;
                // &#x6570;&#x636E;&#x6807;&#x8BC6;&#x7B26;
                writeString('data'); offset += 4;
                // &#x91C7;&#x6837;&#x6570;&#x636E;&#x603B;&#x6570;,&#x5373;&#x6570;&#x636E;&#x603B;&#x5927;&#x5C0F;-44
                data.setUint32(offset, dataLength, true); offset += 4;
                // &#x5199;&#x5165;&#x91C7;&#x6837;&#x6570;&#x636E;
                if (sampleBits === 8) {
                    for (var i = 0; i < bytes.length; i++, offset++) {
                        var s = Math.max(-1, Math.min(1, bytes[i]));
                        var val = s < 0 ? s * 0x8000 : s * 0x7FFF;
                        val = parseInt(255 / (65535 / (val + 32768)));
                        data.setInt8(offset, val, true);
                    }
                } else {
                    for (var i = 0; i < bytes.length; i++, offset += 2) {
                        var s = Math.max(-1, Math.min(1, bytes[i]));
                        data.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true);
                    }
                }
                 return new Blob([data], { type: 'audio/wav' });
            }
        };

        //&#x5F00;&#x59CB;&#x5F55;&#x97F3;
        this.start = function () {
            audioInput.connect(recorder);
            recorder.connect(context.destination);
        }

        //&#x505C;&#x6B62;
        this.stop = function () {
            recorder.disconnect();
        }

        //&#x83B7;&#x53D6;&#x97F3;&#x9891;&#x6587;&#x4EF6;
        this.getBlob = function () {
            this.stop();
            return audioData.encodeWAV();
        }

        //&#x56DE;&#x653E;
        this.play = function (audio) {
            if('msSaveOrOpenBlob' in navigator){
                window.navigator.msSaveOrOpenBlob(res.data, new Date().toISOString() +'.wav');
            }
            var url = window.URL.createObjectURL(this.getBlob());
            //audio&#x6807;&#x7B7E;&#x8DEF;&#x5F84;
            audio.src =  url;
            //&#x521B;&#x5EFA;a&#x6807;&#x7B7E;
            let a = document.createElement('a');
            a.setAttribute("href",url);
            //&#x5229;&#x7528;a&#x6807;&#x7B7E;download&#x5C5E;&#x6027;&#x6765;&#x4E0B;&#x8F7D;&#x6587;&#x4EF6;
            a.setAttribute("download", new Date().toISOString() +'.wav');
            a.click();
        }

        //&#x97F3;&#x9891;&#x91C7;&#x96C6;
        recorder.onaudioprocess = function (e) {
            audioData.input(e.inputBuffer.getChannelData(0));
        }

    };
    //&#x629B;&#x51FA;&#x5F02;&#x5E38;
    HZRecorder.throwError = function (message) {
        alert(message);
        throw new function () { this.toString = function () { return message; } }
    }
    //&#x662F;&#x5426;&#x652F;&#x6301;&#x5F55;&#x97F3;
    HZRecorder.canRecording = (navigator.getUserMedia != null);
    //&#x83B7;&#x53D6;&#x5F55;&#x97F3;&#x673A;
    HZRecorder.get = function (callback, config) {
        if (callback) {
            if (navigator.getUserMedia) {
                navigator.getUserMedia(
                    { audio: true } //&#x53EA;&#x542F;&#x7528;&#x97F3;&#x9891;
                    , function (stream) {
                        var rec = new HZRecorder(stream, config);
                        callback(rec);
                    }
                    , function (error) {
                        console.log(error);
                        switch (error.code || error.name) {
                            case 'PERMISSION_DENIED':
                            case 'PermissionDeniedError':
                                alert('&#x7528;&#x6237;&#x62D2;&#x7EDD;&#x63D0;&#x4F9B;&#x4FE1;&#x606F;&#x3002;')
                                //HZRecorder.throwError('&#x7528;&#x6237;&#x62D2;&#x7EDD;&#x63D0;&#x4F9B;&#x4FE1;&#x606F;&#x3002;');
                                break;
                            case 'NOT_SUPPORTED_ERROR':
                            case 'NotSupportedError':
                                alert('&#x6D4F;&#x89C8;&#x5668;&#x4E0D;&#x652F;&#x6301;&#x786C;&#x4EF6;&#x8BBE;&#x5907;&#x3002;')
                                //HZRecorder.throwError('&#x6D4F;&#x89C8;&#x5668;&#x4E0D;&#x652F;&#x6301;&#x786C;&#x4EF6;&#x8BBE;&#x5907;&#x3002;');
                                break;
                            case 'MANDATORY_UNSATISFIED_ERROR':
                            case 'MandatoryUnsatisfiedError':
                                alert('&#x65E0;&#x6CD5;&#x53D1;&#x73B0;&#x6307;&#x5B9A;&#x7684;&#x786C;&#x4EF6;&#x8BBE;&#x5907;&#x3002;')
                                //HZRecorder.throwError('&#x65E0;&#x6CD5;&#x53D1;&#x73B0;&#x6307;&#x5B9A;&#x7684;&#x786C;&#x4EF6;&#x8BBE;&#x5907;&#x3002;');
                                break;
                            default:
                                alert('&#x65E0;&#x6CD5;&#x6253;&#x5F00;&#x9EA6;&#x514B;&#x98CE;&#x3002;')
                                //HZRecorder.throwError('&#x65E0;&#x6CD5;&#x6253;&#x5F00;&#x9EA6;&#x514B;&#x98CE;&#x3002;&#x5F02;&#x5E38;&#x4FE1;&#x606F;:' + (error.code || error.name));
                                break;
                        }
                    });
            } else {
                alert('&#x5F53;&#x524D;&#x6D4F;&#x89C8;&#x5668;&#x4E0D;&#x652F;&#x6301;&#x5F55;&#x97F3;&#x529F;&#x80FD;&#x3002;')
                //HZRecorder.throwErr('&#x5F53;&#x524D;&#x6D4F;&#x89C8;&#x5668;&#x4E0D;&#x652F;&#x6301;&#x5F55;&#x97F3;&#x529F;&#x80FD;&#x3002;'); return;
            }
        }
    }

    window.HZRecorder = HZRecorder;

})(window);
nodejs
//node &#x6A21;&#x5757;
const express = require('express')
const app = express()
//&#x4E2D;&#x95F4;&#x4EF6;
var bodyParser= require('body-parser');
app.use(bodyParser.urlencoded({extended:false}))
//&#x8BF7;&#x6C42;
const request = require('request');
//&#x8BED;&#x97F3;&#x6587;&#x4EF6;
const fs = require('fs');
//&#x963F;&#x91CC;&#x4E91;&#x8BED;&#x97F3;&#x6A21;&#x5757;
var RPCClient = require('@alicloud/pop-core').RPCClient;

app.all("*", function (req, res, next) {
    //&#x8BBE;&#x7F6E;&#x5141;&#x8BB8;&#x8DE8;&#x57DF;&#x7684;&#x57DF;&#x540D;&#xFF0C;*&#x4EE3;&#x8868;&#x5141;&#x8BB8;&#x4EFB;&#x610F;&#x57DF;&#x540D;&#x8DE8;&#x57DF;
    res.header("Access-Control-Allow-Origin", "*");
    //&#x5141;&#x8BB8;&#x7684;header&#x7C7B;&#x578B;
    res.header("Access-Control-Allow-Headers", "content-type");
    //&#x8DE8;&#x57DF;&#x5141;&#x8BB8;&#x7684;&#x8BF7;&#x6C42;&#x65B9;&#x5F0F;
    res.header("Access-Control-Allow-Methods", "DELETE,PUT,POST,GET,OPTIONS");
    if (req.method.toLowerCase() == 'options')
        res.send(200); //&#x8BA9;options&#x5C1D;&#x8BD5;&#x8BF7;&#x6C42;&#x5FEB;&#x901F;&#x7ED3;&#x675F;
    else
        next();
})

/*
&#x72B6;&#x6001;&#x7801;&#x63CF;&#x8FF0;
&#x63A5;&#x53E3;&#x8BF7;&#x6C42;&#x6210;&#x529F;
    1 &#x8FD4;&#x56DE;&#x8BC6;&#x522B;&#x7ED3;&#x679C;
    2 &#x8BED;&#x97F3;&#x8BC6;&#x522B;&#x5931;&#x8D25;
&#x5176;&#x4F59;&#x9664;&#x5355;&#x6570;&#x4EE5;&#x5916;&#x72B6;&#x6001;&#x7801;
    &#x63A5;&#x53E3;&#x8BF7;&#x6C42;&#x5931;&#x8D25;
3 &#x8BFB;&#x53D6;&#x97F3;&#x9891;&#x6587;&#x4EF6;&#x5931;&#x8D25;
4 token &#x83B7;&#x53D6;&#x5931;&#x8D25;
5 &#x8BF7;&#x6C42;&#x63A5;&#x53E3;&#x8FD4;&#x56DE;&#x9519;&#x8BEF;&#x8BED;
*/

//&#x524D;&#x7AEF;&#x8BF7;&#x6C42;
var data = null;

app.post('/getAudioInfo', function(req, res){
  res.send(data);
});

//&#x8BF7;&#x6C42;&#x53C2;&#x6570;
var appkey = 'g0IXmKkXyorfskOl';
var url = 'http://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/asr';
var audioFile = './audio/2021-10-11T06_30_22.230Z.wav';//&#x5FC5;&#x987B;&#x662F;&#x91C7;&#x6837;&#x7387;16KHZ&#x7684;&#x97F3;&#x9891;&#x6587;&#x4EF6;
var requestUrl = url + '?appkey=' + appkey;

/*
accessKeyId&#x3001;accessKeySecret&#x53EF;&#x5728;&#x8BE5;&#x5730;&#x5740;&#x83B7;&#x53D6;&#xFF1A;
https://ram.console.aliyun.com/manage/ak?spm=a2c4g.11186623.0.0.593d4883rMWyYs
*/
//&#x83B7;&#x53D6;token
var client = new RPCClient({
  accessKeyId:
  accessKeySecret:
  endpoint: 'http://nls-meta.cn-shanghai.aliyuncs.com',  //&#x8BF7;&#x6C42;&#x7684;&#x7F51;&#x5740;
  apiVersion: '2019-02-28'
});

client.request('CreateToken').then((result) => {
    if(result.ErrMsg == ''){
        var token = result.Token.Id;  //&#x8FD4;&#x56DE;token
        process(requestUrl, token, audioFile);  //&#x8C03;&#x7528;&#x8BFB;&#x53D6;&#x97F3;&#x9891;&#x6587;&#x4EF6;
    }else {
        data = {
            code: 4,
            result: result.ErrMsg
        };
    }
});

//&#x8BFB;&#x97F3;&#x9891;&#x6587;&#x4EF6;
function process(requestUrl, token, audioFile) {
    var audioContent = null;
    try {
        audioContent = fs.readFileSync(audioFile);
    } catch (error) {
        if (error.code == 'ENOENT') {
            data = {
                code: 3,
                result: '&#x6587;&#x4EF6;&#x4E0D;&#x5B58;&#x5728;!'
            };
        }
        return;
    }
    var options = {
        url: requestUrl,
        method: 'POST',
        headers: {
            'X-NLS-Token': token,
            'Content-type': 'application/octet-stream',
            'Content-Length': audioContent.length
        },
        body: audioContent
    };
    request(options, function callback(error, response, body) {
    if (error != null) {
        data = {
            code: 5,
            result: error
        }
    }
    else {
        if (response.statusCode == 200) {
            body = JSON.parse(body);
            if (body.status == 20000000) {
                data = {
                    code: 1,
                    result: body.result
                };
                console.log(body.result);
            } else {
                data = {
                    code: 2,
                    result: '&#x8BC6;&#x522B;&#x5931;&#x8D25;!'
                }
            }
        } else {
            data = {
                code: response.statusCode,
                result: '&#x8BC6;&#x522B;&#x5931;&#x8D25;!'
            };
        }
    }

});
}

app.listen(9999, function(){'server start success...'});
使用前端+nodejs+百度云语音识别apk

百度云使用websocket请求方式,进行前端与nodejs交互

html

与阿里云dom结构相同

js

这次与后台交互使用websocket
所以只描述回放部分的代码

[En]

So only describe the code in the playback part

this.play = function (audio) {
    //&#x662F;&#x5426;&#x5177;&#x6709;&#x4FDD;&#x5B58;&#x5E76;&#x6253;&#x5F00;blob&#x7684;&#x65B9;&#x6CD5;
    if('msSaveOrOpenBlob' in navigator){
        window.navigator.msSaveOrOpenBlob(res.data, new Date().toISOString()    +'.wav');
    }
    //audio&#x6807;&#x7B7E;&#x5C5E;&#x6027;&#x8D4B;&#x503C;
    audio.src = window.URL.createObjectURL(this.getBlob());
    //websock&#x7684;&#x8FDE;&#x63A5;&#x5730;&#x5740;
    var ws = new WebSocket("ws://localhost:8181");
    //&#x5EFA;&#x7ACB;ws&#x8FDE;&#x63A5;
    ws.onopen = () =>{
        console.log('Connection to server opened');
        //&#x53D1;&#x9001;&#x8BE5;&#x97F3;&#x9891;&#x6D41;
        ws.send(this.getBlob())
    }
    //&#x83B7;&#x53D6;ws&#x4FE1;&#x606F;
    ws.onmessage = function(e){
        var result_data = JSON.parse(e.data);
        if(result_data.err_no == 0){
            //&#x8FD4;&#x56DE;&#x4FE1;&#x606F;&#x6210;&#x529F;
        }else {
            //&#x51FA;&#x9519;
        }
    }

    ws.onerror = function(){
        //console.log("&#x8FDE;&#x63A5;&#x51FA;&#x9519;");
    }

    ws.onclose = function(e){
        //console.log("&#x670D;&#x52A1;&#x5668;&#x5173;&#x95ED;");
    }
}
nodejs
//&#x767E;&#x5EA6;&#x4E91; &#x8BED;&#x97F3;&#x8BC6;&#x522B;api
let AipSpeech = require("baidu-aip-sdk").speech;
//&#x521B;&#x5EFA;ws&#x670D;&#x52A1;
let Server = require('ws').Server;
//&#x7AEF;&#x53E3;
const wss = new Server({
    port: 8181
})

let resTxt;
//ws&#x8FDE;&#x63A5;
wss.on('connection', ws => {
    console.log('server connected');
    ws.on('message', data => {
        console.log('server recived audio blob');
        // &#x52A1;&#x5FC5;&#x66FF;&#x6362;&#x767E;&#x5EA6;&#x4E91;&#x63A7;&#x5236;&#x53F0;&#x4E2D;&#x65B0;&#x5EFA;&#x767E;&#x5EA6;&#x8BED;&#x97F3;&#x5E94;&#x7528;&#x7684; Api Key &#x548C; Secret Key
        let client = new AipSpeech(0, 'Api Key', 'Secret Key');
        let voiceBase64 = new Buffer(data);
        client.recognize(voiceBase64, 'wav', 16000).then(function(result) {
            resTxt = JSON.stringify(result);
            if(resTxt){
            // &#x5C06;&#x7ED3;&#x679C;&#x4F20;&#x7ED9;&#x524D;&#x7AEF;
                ws.send(resTxt);
            }
        }, function(err) {
            console.log(err);
        });
    })

    //&#x62A5;&#x9519;&#x63D0;&#x793A;&#x4FE1;&#x606F;
    ws.on('error', error => {
        console.log('Error:' + error);
    })
    //&#x5173;&#x95ED;&#x8FDE;&#x63A5;&#x63D0;&#x793A;&#x4FE1;&#x606F;
    ws.on('close', () => {
        console.log('Websocket is closed');
    })
})
//&#x65AD;&#x5F00;&#x8FDE;&#x63A5;&#x63D0;&#x793A;&#x4FE1;&#x606F;
wss.on('disconnection', ws => {
    ws.on('message', msg => {
        console.log('server recived msg:' + msg);
    })
})

Original: https://blog.csdn.net/qq_52769681/article/details/121228369
Author: SanErYa_
Title: 语音识别(html5+nodejs)

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/512147/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球