【问题标题】:How to read and save attachments using node-imap如何使用 node-imap 读取和保存附件
【发布时间】:2022-04-13 16:59:17
【问题描述】:

我正在使用 node-imap,但我找不到一个简单的代码示例,说明如何使用 fs 将使用 node-imap 获取的电子邮件中的附件保存到磁盘。

我已经阅读了几次文档。在我看来,我应该参考消息的特定部分作为附件进行另一次提取。我从基本示例开始:

var Imap = require('imap'),
    inspect = require('util').inspect;

var imap = new Imap({
  user: 'mygmailname@gmail.com',
  password: 'mygmailpassword',
  host: 'imap.gmail.com',
  port: 993,
  tls: true
});

function openInbox(cb) {
  imap.openBox('INBOX', true, cb);
}

imap.once('ready', function() {
  openInbox(function(err, box) {
    if (err) throw err;
    var f = imap.seq.fetch('1:3', {
      bodies: 'HEADER.FIELDS (FROM TO SUBJECT DATE)',
      struct: true
    });
    f.on('message', function(msg, seqno) {
      console.log('Message #%d', seqno);
      var prefix = '(#' + seqno + ') ';
      msg.on('body', function(stream, info) {
        var buffer = '';
        stream.on('data', function(chunk) {
          buffer += chunk.toString('utf8');
        });
        stream.once('end', function() {
          console.log(prefix + 'Parsed header: %s', inspect(Imap.parseHeader(buffer)));
        });
      });
      msg.once('attributes', function(attrs) {
        console.log(prefix + 'Attributes: %s', inspect(attrs, false, 8));

        //Here's were I imagine to need to do another fetch for the content of the message part...

      });
      msg.once('end', function() {
        console.log(prefix + 'Finished');
      });
    });
    f.once('error', function(err) {
      console.log('Fetch error: ' + err);
    });
    f.once('end', function() {
      console.log('Done fetching all messages!');
      imap.end();
    });
  });
});

imap.once('error', function(err) {
  console.log(err);
});

imap.once('end', function() {
  console.log('Connection ended');
});

imap.connect();

这个例子有效。这是带有附件部分的输出:

 [ { partID: '2',
     type: 'application',
     subtype: 'octet-stream',
     params: { name: 'my-file.txt' },
     id: null,
     description: null,
     encoding: 'BASE64',
     size: 44952,
     md5: null,
     disposition:
      { type: 'ATTACHMENT',
        params: { filename: 'my-file.txt' } },
     language: null } ],

如何读取该文件并使用节点的 fs 模块将其保存到磁盘?

【问题讨论】:

  • 您看到的零件ID(在您的示例中为2)是零件号。您想发出 UID FETCH 1234 BINARY.PEEK[2](如果服务器支持 BINARY 扩展)或 BODY.PEEK[2]。 BINARY.PEEK 为您提供原始数据,BODY.PEEK 必须根据您也拥有的编码字段进行解码。此时,您在 RAM 中有一个字符串,我希望您能找到一种使用 node.js 将该字符串写入文件的方法。
  • @arnt 谢谢。 imap.seq.fetch 的第一个参数如何使它执行UID FETCH 1234 BINARY.PEEK[2]?是的,我知道如何流式传输并将其解码到文件中。我将为此使用base64-stream
  • imap.seq.fetch 的第一个参数是 UID,所以如果你想下载(一部分)单个消息,它是一个数字。我想在你的例子中它只是'5'。 '5:*'表示'邮箱中从5到最后一条消息的所有消息,包括'。
  • @arnt 是的,就是这样!我之前尝试过,但似乎我还需要对辅助请求执行imap.fetch,而不是对主提取执行“imap.seq.fetch”。如果你喜欢你可以在这里制定一个答案,如果没有,我会自己做,以便帮助别人。
  • 那么 imap.seq.fetch 可能使用 MSNs 而不是 UIDs。具有最低 UID 的消息具有 MSN 1,具有次低 UID 的消息具有 MSN 2,依此类推。 MSN 发生变化。如有疑问,请坚持使用 UID 并避免使用 MSN。

标签: javascript node.js base64 imap


【解决方案1】:

感谢@arnt 和mscdex 的帮助,我想通了。这是一个完整且有效的脚本,它将所有附件作为文件流式传输到磁盘,同时 base64 动态解码它们。在内存使用方面相当可扩展。

var inspect = require('util').inspect;
var fs      = require('fs');
var base64  = require('base64-stream');
var Imap    = require('imap');
var imap    = new Imap({
  user: 'mygmailname@gmail.com',
  password: 'mygmailpassword',
  host: 'imap.gmail.com',
  port: 993,
  tls: true
  //,debug: function(msg){console.log('imap:', msg);}
});

function toUpper(thing) { return thing && thing.toUpperCase ? thing.toUpperCase() : thing;}

function findAttachmentParts(struct, attachments) {
  attachments = attachments ||  [];
  for (var i = 0, len = struct.length, r; i < len; ++i) {
    if (Array.isArray(struct[i])) {
      findAttachmentParts(struct[i], attachments);
    } else {
      if (struct[i].disposition && ['INLINE', 'ATTACHMENT'].indexOf(toUpper(struct[i].disposition.type)) > -1) {
        attachments.push(struct[i]);
      }
    }
  }
  return attachments;
}

function buildAttMessageFunction(attachment) {
  var filename = attachment.params.name;
  var encoding = attachment.encoding;

  return function (msg, seqno) {
    var prefix = '(#' + seqno + ') ';
    msg.on('body', function(stream, info) {
      //Create a write stream so that we can stream the attachment to file;
      console.log(prefix + 'Streaming this attachment to file', filename, info);
      var writeStream = fs.createWriteStream(filename);
      writeStream.on('finish', function() {
        console.log(prefix + 'Done writing to file %s', filename);
      });

      //stream.pipe(writeStream); this would write base64 data to the file.
      //so we decode during streaming using 
      if (toUpper(encoding) === 'BASE64') {
        //the stream is base64 encoded, so here the stream is decode on the fly and piped to the write stream (file)
        stream.pipe(base64.decode()).pipe(writeStream);
      } else  {
        //here we have none or some other decoding streamed directly to the file which renders it useless probably
        stream.pipe(writeStream);
      }
    });
    msg.once('end', function() {
      console.log(prefix + 'Finished attachment %s', filename);
    });
  };
}

imap.once('ready', function() {
  imap.openBox('INBOX', true, function(err, box) {
    if (err) throw err;
    var f = imap.seq.fetch('1:3', {
      bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'],
      struct: true
    });
    f.on('message', function (msg, seqno) {
      console.log('Message #%d', seqno);
      var prefix = '(#' + seqno + ') ';
      msg.on('body', function(stream, info) {
        var buffer = '';
        stream.on('data', function(chunk) {
          buffer += chunk.toString('utf8');
        });
        stream.once('end', function() {
          console.log(prefix + 'Parsed header: %s', Imap.parseHeader(buffer));
        });
      });
      msg.once('attributes', function(attrs) {
        var attachments = findAttachmentParts(attrs.struct);
        console.log(prefix + 'Has attachments: %d', attachments.length);
        for (var i = 0, len=attachments.length ; i < len; ++i) {
          var attachment = attachments[i];
          /*This is how each attachment looks like {
              partID: '2',
              type: 'application',
              subtype: 'octet-stream',
              params: { name: 'file-name.ext' },
              id: null,
              description: null,
              encoding: 'BASE64',
              size: 44952,
              md5: null,
              disposition: { type: 'ATTACHMENT', params: { filename: 'file-name.ext' } },
              language: null
            }
          */
          console.log(prefix + 'Fetching attachment %s', attachment.params.name);
          var f = imap.fetch(attrs.uid , { //do not use imap.seq.fetch here
            bodies: [attachment.partID],
            struct: true
          });
          //build function to process attachment message
          f.on('message', buildAttMessageFunction(attachment));
        }
      });
      msg.once('end', function() {
        console.log(prefix + 'Finished email');
      });
    });
    f.once('error', function(err) {
      console.log('Fetch error: ' + err);
    });
    f.once('end', function() {
      console.log('Done fetching all messages!');
      imap.end();
    });
  });
});

imap.once('error', function(err) {
  console.log(err);
});

imap.once('end', function() {
  console.log('Connection ended');
});

imap.connect();

【讨论】:

  • 知道如何同时获取草稿、已发送和其他文件夹或所有文件夹的消息。真的很有帮助,谢谢
  • 去寻找如何通过 IMAP 读取这些文件夹。然后将您找到的任何内容与我的答案结合起来。我发现了这个:apple.stackexchange.com/a/201346/122588。如果在我的回答中您将 INBOX 替换为 ALL 怎么办?可能行不通,但它可以让您了解获得所需内容的过程。
  • 注意大小写比较,因为在我的情况下,响应对象中的所有属性都只有小写值,但在示例中都是大写的。而且if (struct[i].disposition &amp;&amp; ['INLINE', 'ATTACHMENT'].indexOf(struct[i].disposition.type) &gt; -1) { attachments.push(struct[i]); }必须改成if (struct[i].disposition &amp;&amp; (['ATTACHMENT'].indexOf(struct[i].disposition.type) &gt; -1||['attachment'].indexOf(struct[i].disposition.type) &gt; -1)) { attachments.push(struct[i]); }
  • @Vladuysha 添加了安全案例比较。谢谢
  • 感谢您的建议!我在获取附件时遇到了一个小问题。在获取附件并调用 buildAttMessageFunction() 时,我引入了一个新变量 af 而不是 f。然而,不幸的是 af.on('message', ...) 回调永远不会被执行。其他人有这个问题吗?我试图通过查看命令来调试库——>使用普通的 telnet 它可以工作!只是回调没有被调用/消息事件没有被触发
【解决方案2】:

基于克里斯蒂安·韦斯特贝克

更改:1. 使用 =>,forEach; 2. 2nd fetch 不需要“struct”。

问题:

在某些情况下,附件的文件名应该是 attachment.disposition.params['filename*']。请参阅“RFC2231 MIME 参数值和编码字扩展”和here

const fs = require('fs')
const base64 = require('base64-stream')
const Imap = require('imap')

const imap = new Imap({
  user: 'XXX@126.com',
  password: 'XXXXX',
  host: 'imap.126.com',
  port: 993,
  tls: true /*,
  debug: (msg) => {console.log('imap:', msg);} */
});

function toUpper(thing) { return thing && thing.toUpperCase ? thing.toUpperCase() : thing }

function findAttachmentParts(struct, attachments) {
  attachments = attachments ||  []
  struct.forEach((i) => {
    if (Array.isArray(i)) findAttachmentParts(i, attachments)
    else if (i.disposition && ['INLINE', 'ATTACHMENT'].indexOf(toUpper(i.disposition.type)) > -1) {
      attachments.push(i)
    }
  })
  return attachments
}

imap.once('ready', () => {
  // A4 EXAMINE "INBOX"
  imap.openBox('INBOX', true, (err, box) => { 
    if (err) throw err;
    // A5 FETCH 1:3 (UID FLAGS INTERNALDATE BODYSTRUCTURE BODY.PEEK[HEADER.FIELDS (SUBJECT DATE)])
    const f = imap.seq.fetch('1:3', {
      bodies: ['HEADER.FIELDS (SUBJECT)'],
      struct: true  // BODYSTRUCTURE
    }) 
    f.on('message', (msg, seqno) => {
      console.log('Message #%d', seqno)
      const prefix = `(#${seqno})`
      var header = null
      msg.on('body', (stream, info) => {
        var buffer = ''
        stream.on('data', (chunk) => { buffer += chunk.toString('utf8') });
        stream.once('end', () => { header = Imap.parseHeader(buffer) })
      });
      msg.once('attributes', (attrs) => {
        const attachments = findAttachmentParts(attrs.struct);
        console.log(`${prefix} uid=${attrs.uid} Has attachments: ${attachments.length}`);
        attachments.forEach((attachment) => {
        /* 
          RFC2184 MIME Parameter Value and Encoded Word Extensions
                  4.Parameter Value Character Set and Language Information
          RFC2231 Obsoletes: 2184
          {
            partID: "2",
            type: "image",
            subtype: "jpeg",
            params: {
    X         "name":"________20.jpg",
              "x-apple-part-url":"8C33222D-8ED9-4B10-B05D-0E028DEDA92A"
            },
            id: null,
            description: null,
            encoding: "base64",
            size: 351314,
            md5: null,
            disposition: {
              type: "inline",
              params: {
    V           "filename*":"GB2312''%B2%E2%CA%D4%B8%BD%BC%FE%D2%BB%5F.jpg"
              }
            },
            language: null
          }   */            
          console.log(`${prefix} Fetching attachment $(attachment.params.name)`)
          console.log(attachment.disposition.params["filename*"])
          const filename = attachment.params.name  // need decode disposition.params['filename*'] !!!
          const encoding = toUpper(attachment.encoding)
          // A6 UID FETCH {attrs.uid} (UID FLAGS INTERNALDATE BODY.PEEK[{attachment.partID}])
          const f = imap.fetch(attrs.uid, { bodies: [attachment.partID] })
          f.on('message', (msg, seqno) => {
            const prefix = `(#${seqno})`
            msg.on('body', (stream, info) => {
              const writeStream = fs.createWriteStream(filename);
              writeStream.on('finish', () => { console.log(`${prefix} Done writing to file ${filename}`) })
              if (encoding === 'BASE64') stream.pipe(base64.decode()).pipe(writeStream)
              else stream.pipe(writeStream)
            })
            msg.once('end', () => { console.log(`${prefix} Finished attachment file${filename}`) })
          })
          f.once('end', () => { console.log('WS: downloder finish') })
        })
      })
      msg.once('end', () => { console.log(`${prefix} Finished email`); })
    });
    f.once('error', (err) => { console.log(`Fetch error: ${err}`) })
    f.once('end', () => {
      console.log('Done fetching all messages!')
      imap.end()
    })
  })
})
imap.once('error', (err) => { console.log(err) })
imap.once('end', () => { console.log('Connection ended') })
imap.connect()

【讨论】:

    【解决方案3】:

    您也可以将其用于 me.for gmail。

    const IMAP = require("imap");
    const MailParser = require("mailparser").MailParser;
    const moment = require('moment');
    var fs = require('fs'), fileStream;
    module.exports.imapEmailDownload = function () {
    return new Promise(async (resolve, reject) => {
        try {
            const imapConfig = {
                user: 'XXX126@gmail.com',
                password: 'XXX@126',
                host: 'imap.gmail.com',
                port: '993',
                tls: true,
                tlsOptions: {
                    secureProtocol: 'TLSv1_method'
                }
            }
            const imap = IMAP(imapConfig);
    
            imap.once("ready", execute);
            imap.once("error", function (err) {
                console.error("Connection error: " + err.stack);
            });
    
            imap.connect();
    
            function execute() {
                imap.openBox("INBOX", false, function (err, mailBox) {
                    if (err) {
                        console.error(err);
                        return;
                    }
                    imap.search([["ON", moment().format('YYYY-MM-DD')]], function (err, results) {
                        if (!results || !results.length) { console.log("No unread mails"); imap.end(); return; }
                        /* mark as seen
                        imap.setFlags(results, ['\\Seen'], function(err) {
                            if (!err) {
                                console.log("marked as read");
                            } else {
                                console.log(JSON.stringify(err, null, 2));
                            }
                        });*/
                        var f = imap.fetch(results, { bodies: "" });
                        f.on("message", processMessage);
                        f.once("error", function (err) {
                            return Promise.reject(err);
                        });
                        f.once("end", function () {
                            imap.end();
                        });
                    });
                });
            }
    
            function processMessage(msg, seqno) {
              
                var parser = new MailParser({ streamAttachments: true });
                parser.on("headers", function (headers) {
                });
    
                parser.on('data', data => {
                    if (data.type === 'text') {
                        console.log(seqno);
                        console.log(data.text);  /* data.html*/
                    }
    
                });
                let data = ""
                msg.on("body", function (stream) {
                    stream.on("data", function (chunk) {
                        data = data + chunk.toString("utf8");
                        parser.write(chunk.toString("utf8"));
                    });
                    stream.on("end", (chunk) => {
                    })
                });
    
                parser.on('attachment', async function (attachment, mail) {
                    let filepath = './download/';
                    let output = fs.createWriteStream(filepath + attachment.fileName);
                    
                    attachment.stream.pipe(output).on("end", function () {
                        console.log("All the data in the file has been read");
                    }).on("close", function (err) {
                        console.log("Stream has been cloesd.");
                    });
    
                });
    
                msg.once("end", function () {
                    // console.log("Finished msg #" + seqno);
                    parser.end();
                });
            }
            resolve();
        } catch (error) {
            console.log("error", error);
            reject(error);
        }
    });};
    

    【讨论】:

      【解决方案4】:

      下面的解释是用于从电子邮件中下载附件,还有一个标志(markAsRead)将其设置为 true 以仅读取未读邮件,设置为 false 用于下载所有附件。

      仅获取未读/未见过的电子邮件:您必须将获取调用包装在回调中以进行搜索,如下所示:

       imap.search(
            ['UNSEEN'],
            function(err, results) {
                // current code
            }
      );
      

      将电子邮件标记为已读:对我来说是一个棘手的问题,在第 53 行,打开收件箱的调用是这样的: imap.openBox('INBOX', true, function(err, box) { 第二个参数(真值)用于以只读模式打开收件箱。您需要将其更改为 false,然后在第二个参数中添加一个字段 markSeen: true:

      var f = imap.seq.fetch('1:*', {
            bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'],
            struct: true,
            markSeen: true // <---- this is new
      });
      

      所以,这是我现在正在使用的脚本,更改是:

      将邮件标记为已读:如果配置选项 imapOptions.markAsRead 设置为 true,它会将处理过的邮件标记为已读。 文件名格式:有一个配置选项 (downloads.filenameFormat) 可用于重命名文件。这真的很简单。如果您将其设置为 $FILENAME 或只是将其删除,它将保留原始文件名。我包含它是因为人们发送的文件名称相同,但内容不同,我需要保留它们。 日志:我使用 simple-node-logger 包添加了日志。脚本使用两个级别:调试显示原始脚本中的所有内容,以及更简单的日志信息。如果您只需要它,也会使用错误级别。

      const config = require('./config.json');
      const markAsRead = (config.imapOptions && config.imapOptions.markAsRead) ? config.imapOptions.markAsRead : false;
      
      const fs = require('fs');
      const { Base64Decode } = require('base64-stream')
      
      const Imap = require('imap');
      const imap = new Imap(config.imap);
      
      // Simple logger:
      const logger = require('simple-node-logger').createSimpleLogger( config.logs?.simpleNodeLogger || { logFilePath:'mail-downloader.log', timestampFormat:'YYYY-MM-DD HH:mm:ss.SSS' } );
      logger.setLevel(config.logs?.level || 'debug');
      
      // var emailDate;
      // var emailFrom;
      function formatFilename(filename, emailFrom, emailDate) {
        // defaults to current filename:
        let name = filename;
        // if custom config is present:
        if (config.downloads) {
          // if format provided, use it to build filename:
          if (config.downloads.filenameFormat) {
            name = config.downloads.filenameFormat;
            // converts from field from "Full Name <fullname@mydomain.com>" into "fullname":
            name = name.replace('$FROM', emailFrom.replace(/.*</i, '').replace('>', '').replace(/@.*/i, ''));
            // parses text date and uses timestamp:
            name = name.replace('$DATE', new Date(emailDate).getTime());
            name = name.replace('$FILENAME', filename);
          }
          // if directory provided, use it:
          if (config.downloads.directory) name = `${config.downloads.directory}/${name}`;
        }
        // return formatted filename:
        return name;
      }
      
      function findAttachmentParts(struct, attachments) {
        attachments = attachments ||  [];
        for (var i = 0, len = struct.length, r; i < len; ++i) {
          if (Array.isArray(struct[i])) {
            findAttachmentParts(struct[i], attachments);
          } else {
            if (struct[i].disposition && ['inline', 'attachment'].indexOf(struct[i].disposition.type.toLowerCase()) > -1) {
              attachments.push(struct[i]);
            }
          }
        }
        return attachments;
      }
      
      function buildAttMessageFunction(attachment, emailFrom, emailDate) {
        const filename = attachment.params.name;
        const encoding = attachment.encoding;
      
        return function (msg, seqno) {
          var prefix = '(#' + seqno + ') ';
          msg.on('body', function(stream, info) {
            //Create a write stream so that we can stream the attachment to file;
            logger.debug(prefix + 'Streaming this attachment to file', filename, info);
            var writeStream = fs.createWriteStream(formatFilename(filename, emailFrom, emailDate));
            writeStream.on('finish', function() {
              logger.debug(prefix + 'Done writing to file %s', filename);
            });
      
            //so we decode during streaming using 
            if (encoding.toLowerCase() === 'base64') {
              //the stream is base64 encoded, so here the stream is decode on the fly and piped to the write stream (file)
              stream.pipe(new Base64Decode()).pipe(writeStream)
            } else  {
              //here we have none or some other decoding streamed directly to the file which renders it useless probably
              stream.pipe(writeStream);
            }
          });
          msg.once('end', function() {
            logger.debug(prefix + 'Finished attachment %s', filename);
            logger.info(`Attachment downloaded: ${filename}`)
          });
        };
      }
      
      imap.once('ready', function() {
        logger.info('Connected');
        imap.openBox('INBOX', !markAsRead, function(err, box) {
      
          if (err) throw err;
          imap.search(
            ['UNSEEN'],
            function(err, results) {
              if (err) throw err;
      
              if (!results.length) {
                // if now unread messages, log and end connection:
                logger.info('No new emails found');
                imap.end();
      
              } else {
                logger.info(`Found ${results.length} unread emails`)
                // if unread messages, fetch and process:
                var f = imap.fetch(results, {
                  bodies: ['HEADER.FIELDS (FROM TO SUBJECT DATE)'],
                  struct: true,
                  markSeen: markAsRead
                });
        
                f.on('message', function (msg, seqno) {
                  logger.debug('Message #%d', seqno);
                  const prefix = '(#' + seqno + ') ';
      
                  var emailDate;
                  var emailFrom;
      
                  msg.on(
                    'body',
                    function(stream, info) {
                      var buffer = '';
                      stream.on('data', function(chunk) {
                        buffer += chunk.toString('utf8');
                      });
                      stream.once('end', function() {
                        const parsedHeader = Imap.parseHeader(buffer);
                        logger.debug(prefix + 'Parsed header: %s', parsedHeader);
                        // set to global vars so they can be used later to format filename:
                        emailFrom = parsedHeader.from[0];
                        emailDate = parsedHeader.date[0];
                        logger.info(`Email from ${emailFrom} with date ${emailDate}`);
                      });
                    }
                  );
            
                  msg.once(
                    'attributes',
                    function(attrs) {
                      const attachments = findAttachmentParts(attrs.struct);
                      logger.debug(prefix + 'Has attachments: %d', attachments.length);
                      logger.info(`Email with ${attachments.length} attachemnts`);
                      for (var i = 0, len=attachments.length ; i < len; ++i) {
                        const attachment = attachments[i];
                        logger.debug(prefix + 'Fetching attachment %s', attachment.params.name);
                        var f = imap.fetch(attrs.uid , {
                          bodies: [attachment.partID],
                          struct: true
                        });
                        //build function to process attachment message
                        f.on('message', buildAttMessageFunction(attachment, emailFrom, emailDate));
                      }
                    }
                  );
            
                  msg.once(
                    'end',
                    function() {
                      logger.debug(prefix + 'Finished email');
                    }
                  );
                });
            
                f.once('error', function(err) {
                  logger.error('Fetch error: ' + err);
                });
            
                f.once('end', function() {
                  logger.info('Done fetching all messages!');
                  imap.end();
                });
      
              }
      
            }
          );
      
        });
      });
      
      imap.once('error', function(err) {
        logger.error(err);
      });
      
      imap.once('end', function() {
        logger.info('Connection ended');
      });
      
      imap.connect();
      

      这是带有新选项的配置文件:

      {
        "imap": {
          "user": "myuser@maydomain.com",
          "password": "myPassword",
          "host": "myImapServer",
          "port": 993,
          "tls": true
        },
        "imapOptions": {
          "markAsRead": false
        },
        "downloads": {
          "directory": "./downloads",
          "filenameFormat": "$DATE_$FROM_$FILENAME"
        },
        "logs": {
          "level": "info",
          "simpleNodeLogger": {
            "logFilePath": "mail-downloader.log",
            "timestampFormat": "YYYY-MM-DD HH:mm:ss.SSS"
          }
        }
      }
      

      干杯!

      【讨论】:

      猜你喜欢
      • 2015-12-05
      • 2019-03-11
      • 1970-01-01
      • 2018-08-03
      • 2013-01-27
      • 1970-01-01
      • 2021-08-28
      • 1970-01-01
      • 2014-03-19
      相关资源
      最近更新 更多