当前位置:   article > 正文

AI智能问答系统(2):具体实现_ai问答实现实验

ai问答实现实验

11.3  具体实现

本项目将使用TensorFlow.js设计一个网页,在网页中有一篇文章。然后利用SQuAD2.0数据集,和神经模型MobileBERT学习文章中的知识,然后在表单中提问和文章内容有关的问题,系统会自动回答这个问题。

11.3.1  编写HTML文件

编写HTML文件index.html,在上方文本框中显示介绍尼古拉·特斯拉的一篇文章信息,在下方文本框输入一个和文章内容相关的问题,单击“search”按钮后会自动输出显示这个问题的答案。文件index.html的具体实现代码如下:

  1. <!doctype html>
  2. <html>
  3. <head>
  4. <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
  5. <script src="./index.js"></script>
  6. </head>
  7. <body>
  8. <div>
  9. <h3>Context (you can paste your own content in the text area)</h3>
  10. <textarea id='context' rows="30" cols="120">Nikola Tesla (/ˈtɛslə/;[2] Serbo-Croatian: [nǐkola têsla]; Serbian Cyrillic: Никола Тесла;[a] 10
  11. July 18567 January 1943) was a Serbian-American[4][5][6] inventor, electrical engineer, mechanical engineer,
  12. and futurist who is best known for his contributions to the design of the modern alternating current (AC)
  13. electricity supply system.[7] <br/>
  14. Born and raised in the Austrian Empire, Tesla studied engineering and physics in the 1870s without receiving a
  15. degree, and gained practical experience in the early 1880s working in telephony and at Continental Edison in the
  16. new electric power industry. He emigrated in 1884 to the United States, where he would become a naturalized
  17. citizen. He worked for a short time at the Edison Machine Works in New York City before he struck out on his own.
  18. With the help of partners to finance and market his ideas, Tesla set up laboratories and companies in New York to
  19. develop a range of electrical and mechanical devices. His alternating current (AC) induction motor and related
  20. polyphase AC patents, licensed by Westinghouse Electric in 1888, earned him a considerable amount of money and
  21. became the cornerstone of the polyphase system which that company would eventually market.<br/>
  22. Attempting to develop inventions he could patent and market, Tesla conducted a range of experiments with
  23. mechanical oscillators/generators, electrical discharge tubes, and early X-ray imaging. He also built a
  24. wireless-controlled boat, one of the first ever exhibited. Tesla became well known as an inventor and would
  25. demonstrate his achievements to celebrities and wealthy patrons at his lab, and was noted for his showmanship at
  26. public lectures. Throughout the 1890s, Tesla pursued his ideas for wireless lighting and worldwide wireless
  27. electric power distribution in his high-voltage, high-frequency power experiments in New York and Colorado
  28. Springs. In 1893, he made pronouncements on the possibility of wireless communication with his devices. Tesla
  29. tried to put these ideas to practical use in his unfinished Wardenclyffe Tower project, an intercontinental
  30. wireless communication and power transmitter, but ran out of funding before he could complete it.[8]<br/>
  31. After Wardenclyffe, Tesla experimented with a series of inventions in the 1910s and 1920s with varying degrees of
  32. success. Having spent most of his money, Tesla lived in a series of New York hotels, leaving behind unpaid bills.
  33. He died in New York City in January 1943.[9] Tesla's work fell into relative obscurity following his death, until
  34. 1960, when the General Conference on Weights and Measures named the SI unit of magnetic flux density the tesla in
  35. his honor.[10] There has been a resurgence in popular interest in Tesla since the 1990s.[11]</textarea>
  36. <h3>Question</h3>
  37. <input type=text id="question"> <button id="search">Search</button>
  38. <h3>Answers</h3>
  39. <div id='answer'></div>
  40. </div>
  41. </body>
  42. </html>

11.3.2  脚本处理

当用户单击“search”按钮后会调用脚本文件index.js,此文件的功能是获取用户在文本框中输入的问题,然后调用神经网络模型回答这个问题。文件index.js的具体实现代码如下所示。

  1. import * as qna from '@tensorflow-models/qna';
  2. import '@tensorflow/tfjs-core';
  3. import '@tensorflow/tfjs-backend-cpu';
  4. import '@tensorflow/tfjs-backend-webgl';
  5. let modelPromise = {};
  6. let search;
  7. let input;
  8. let contextDiv;
  9. let answerDiv;
  10. const process = async () => {
  11. const model = await modelPromise;
  12. const answers = await model.findAnswers(input.value, contextDiv.value);
  13. console.log(answers);
  14. answerDiv.innerHTML =
  15. answers.map(answer => answer.text + ' (score =' + answer.score + ')')
  16. .join('<br>');
  17. };
  18. window.onload = () => {
  19. modelPromise = qna.load();
  20. input = document.getElementById('question');
  21. search = document.getElementById('search');
  22. contextDiv = document.getElementById('context');
  23. answerDiv = document.getElementById('answer');
  24. search.onclick = process;
  25. input.addEventListener('keyup', async (event) => {
  26. if (event.key === 'Enter') {
  27. process();
  28. }
  29. });
  30. };

在上述代码中,使用addEventListener监听用户输入的问题,然后调用函数model.findAnswers()回答问题。

11.3.3  加载训练模型

在文件question_and_answer.ts中加载神经网络模型MobileBERT,具体实现流程如下:

(1)首先设置输入参数和最大扫描长度,代码如下:

  1. const MODEL_URL = 'https://tfhub.dev/tensorflow/tfjs-model/mobilebert/1';
  2. const INPUT_SIZE = 384;
  3. const MAX_ANSWER_LEN = 32;
  4. const MAX_QUERY_LEN = 64;
  5. const MAX_SEQ_LEN = 384;
  6. const PREDICT_ANSWER_NUM = 5;
  7. const OUTPUT_OFFSET = 1;
  8. const NO_ANSWER_THRESHOLD = 4.3980759382247925;

在上述代码中,NO_ANSWER_THRESHOLD是确定问题是否与上下文无关的阈值,该值是由训练SQuAD2.02.0数据集的数据生成的。

(2)创建加载模型MobileBert的接口ModelConfig,代码如下:

  1. export interface ModelConfig {
  2.   /**
  3.    *指定模型的自定义url的可选字符串,这对无法访问托管在上的模型的地区/国家/地区很有用
  4. .
  5.   */
  6.   modelUrl: string;
  7.   /**
  8.    * 是否是来自tfhub的URL
  9.    */
  10.   fromTFHub?: boolean;
  11. }

11.3.4  查询处理

编写函数process()实现检索处理,获取用户在表单中输入的问题,然后检索文章中的所有内容。为了确保问题的完整性,如果用户没有在问题最后输入问号,会自动添加一个问号。代码如下:

  1. private process(
  2. query: string, context: string, maxQueryLen: number, maxSeqLen: number,
  3. docStride = 128): Feature[] {
  4. // 始终在查询末尾添加问号.
  5. query = query.replace(/\?/g, '');
  6. query = query.trim();
  7. query = query + '?';
  8. const queryTokens = this.tokenizer.tokenize(query);
  9. if (queryTokens.length > maxQueryLen) {
  10. throw new Error(
  11. `The length of question token exceeds the limit (${maxQueryLen}).`);
  12. }
  13. const origTokens = this.tokenizer.processInput(context.trim());
  14. const tokenToOrigIndex: number[] = [];
  15. const allDocTokens: number[] = [];
  16. for (let i = 0; i < origTokens.length; i++) {
  17. const token = origTokens[i].text;
  18. const subTokens = this.tokenizer.tokenize(token);
  19. for (let j = 0; j < subTokens.length; j++) {
  20. const subToken = subTokens[j];
  21. tokenToOrigIndex.push(i);
  22. allDocTokens.push(subToken);
  23. }
  24. }
  25. // 3个选项: [CLS], [SEP] and [SEP]
  26. const maxContextLen = maxSeqLen - queryTokens.length - 3;
  27. // 我们可以有超过最大序列长度的文档。为了解决这个问题,我们采用了滑动窗口的方法,
  28. // 在这种方法中,我们以“doc\u-stride”的步幅将大块的数据移动到最大长度。
  29. const docSpans: Array<{start: number, length: number}> = [];
  30. let startOffset = 0;
  31. while (startOffset < allDocTokens.length) {
  32. let length = allDocTokens.length - startOffset;
  33. if (length > maxContextLen) {
  34. length = maxContextLen;
  35. }
  36. docSpans.push({start: startOffset, length});
  37. if (startOffset + length === allDocTokens.length) {
  38. break;
  39. }
  40. startOffset += Math.min(length, docStride);
  41. }
  42. const features = docSpans.map(docSpan => {
  43. const tokens = [];
  44. const segmentIds = [];
  45. const tokenToOrigMap: {[index: number]: number} = {};
  46. tokens.push(CLS_INDEX);
  47. segmentIds.push(0);
  48. for (let i = 0; i < queryTokens.length; i++) {
  49. const queryToken = queryTokens[i];
  50. tokens.push(queryToken);
  51. segmentIds.push(0);
  52. }
  53. tokens.push(SEP_INDEX);
  54. segmentIds.push(0);
  55. for (let i = 0; i < docSpan.length; i++) {
  56. const splitTokenIndex = i + docSpan.start;
  57. const docToken = allDocTokens[splitTokenIndex];
  58. tokens.push(docToken);
  59. segmentIds.push(1);
  60. tokenToOrigMap[tokens.length] = tokenToOrigIndex[splitTokenIndex];
  61. }
  62. tokens.push(SEP_INDEX);
  63. segmentIds.push(1);
  64. const inputIds = tokens;
  65. const inputMask = inputIds.map(id => 1);
  66. while ((inputIds.length < maxSeqLen)) {
  67. inputIds.push(0);
  68. inputMask.push(0);
  69. segmentIds.push(0);
  70. }
  71. return {inputIds, inputMask, segmentIds, origTokens, tokenToOrigMap};
  72. });
  73. return features;
  74. }

11.3.5  文章处理

(1)编写函数cleanText(),功能是删除文章中文本中的无效字符和空白。代码如下:

  1. tokenize(text: string): number[] {
  2. let outputTokens: number[] = [];
  3. const words = this.processInput(text);
  4. words.forEach(word => {
  5. if (word.text !== CLS_TOKEN && word.text !== SEP_TOKEN) {
  6. word.text = `${SEPERATOR}${word.text.normalize(NFKC_TOKEN)}`;
  7. }
  8. });
  9. for (let i = 0; i < words.length; i++) {
  10. const chars = [];
  11. for (const symbol of words[i].text) {
  12. chars.push(symbol);
  13. }
  14. let isUnknown = false;
  15. let start = 0;
  16. const subTokens: number[] = [];
  17. const charsLength = chars.length;
  18. while (start < charsLength) {
  19. let end = charsLength;
  20. let currIndex;
  21. while (start < end) {
  22. const substr = chars.slice(start, end).join('');
  23. const match = this.trie.find(substr);
  24. if (match != null && match.end != null) {
  25. currIndex = match.getWord()[2];
  26. break;
  27. }
  28. end = end - 1;
  29. }
  30. if (currIndex == null) {
  31. isUnknown = true;
  32. break;
  33. }
  34. subTokens.push(currIndex);
  35. start = end;
  36. }
  37. if (isUnknown) {
  38. outputTokens.push(UNK_INDEX);
  39. } else {
  40. outputTokens = outputTokens.concat(subTokens);
  41. }
  42. }
  43. return outputTokens;
  44. }
  45. }
  1. tokenize(text: string): number[] {
  2. let outputTokens: number[] = [];
  3. const words = this.processInput(text);
  4. words.forEach(word => {
  5. if (word.text !== CLS_TOKEN && word.text !== SEP_TOKEN) {
  6. word.text = `${SEPERATOR}${word.text.normalize(NFKC_TOKEN)}`;
  7. }
  8. });
  9. for (let i = 0; i < words.length; i++) {
  10. const chars = [];
  11. for (const symbol of words[i].text) {
  12. chars.push(symbol);
  13. }
  14. let isUnknown = false;
  15. let start = 0;
  16. const subTokens: number[] = [];
  17. const charsLength = chars.length;
  18. while (start < charsLength) {
  19. let end = charsLength;
  20. let currIndex;
  21. while (start < end) {
  22. const substr = chars.slice(start, end).join('');
  23. const match = this.trie.find(substr);
  24. if (match != null && match.end != null) {
  25. currIndex = match.getWord()[2];
  26. break;
  27. }
  28. end = end - 1;
  29. }
  30. if (currIndex == null) {
  31. isUnknown = true;
  32. break;
  33. }
  34. subTokens.push(currIndex);
  35. start = end;
  36. }
  37. if (isUnknown) {
  38. outputTokens.push(UNK_INDEX);
  39. } else {
  40. outputTokens = outputTokens.concat(subTokens);
  41. }
  42. }
  43. return outputTokens;
  44. }
  45. }

(3)编写函数tokenize(),功能是为指定的词汇库生成标记。本函数使用谷歌提供的全词屏蔽模型实现,这种新技术也被称为全词掩码。在这种情况下,总是一次屏蔽与一个单词对应的所有标记。对应Python实现请参阅谷歌提供的开源代码:https://github.com/google-research/bert/blob/88a817c37f788702a363ff935fd173b6dc6ac0d6/tokenization.py。

  1. tokenize(text: string): number[] {
  2. let outputTokens: number[] = [];
  3. const words = this.processInput(text);
  4. words.forEach(word => {
  5. if (word.text !== CLS_TOKEN && word.text !== SEP_TOKEN) {
  6. word.text = `${SEPERATOR}${word.text.normalize(NFKC_TOKEN)}`;
  7. }
  8. });
  9. for (let i = 0; i < words.length; i++) {
  10. const chars = [];
  11. for (const symbol of words[i].text) {
  12. chars.push(symbol);
  13. }
  14. let isUnknown = false;
  15. let start = 0;
  16. const subTokens: number[] = [];
  17. const charsLength = chars.length;
  18. while (start < charsLength) {
  19. let end = charsLength;
  20. let currIndex;
  21. while (start < end) {
  22. const substr = chars.slice(start, end).join('');
  23. const match = this.trie.find(substr);
  24. if (match != null && match.end != null) {
  25. currIndex = match.getWord()[2];
  26. break;
  27. }
  28. end = end - 1;
  29. }
  30. if (currIndex == null) {
  31. isUnknown = true;
  32. break;
  33. }
  34. subTokens.push(currIndex);
  35. start = end;
  36. }
  37. if (isUnknown) {
  38. outputTokens.push(UNK_INDEX);
  39. } else {
  40. outputTokens = outputTokens.concat(subTokens);
  41. }
  42. }
  43. return outputTokens;
  44. }
  45. }

11.3.6  加载处理

编写函数load()加载数据和网页信息,首先使用函数loadGraphModel()加载模型文件,然后使用函数execute()执行根据用户输入的操作。代码如下:

  1.   async load() {
  2.     this.model = await tfconv.loadGraphModel(
  3.         this.modelConfig.modelUrl, {fromTFHub: this.modelConfig.fromTFHub});
  4.     //预热后端
  5.     const batchSize = 1;
  6.     const inputIds = tf.ones([batchSize, INPUT_SIZE], 'int32');
  7.     const segmentIds = tf.ones([1, INPUT_SIZE], 'int32');
  8.     const inputMask = tf.ones([1, INPUT_SIZE], 'int32');
  9.     this.model.execute({
  10.       input_ids: inputIds,
  11.       segment_ids: segmentIds,
  12.       input_mask: inputMask,
  13.       global_step: tf.scalar(1, 'int32')
  14.     });
  15.     this.tokenizer = await loadTokenizer();
  16.   }

11.3.7  寻找答案

编写函数model.findAnswers(),功能是根据用户在表单中输入的问题寻找对应的答案。此函数包含如下3个参数:

  1. question:要找答案的问题。
  2. context:从这里面查找答案。
  3. 返回值是一个数组,每个选项是一种可能的答案。

函数model.findAnswers()的具体实现代码如下所示。

  1. async findAnswers(question: string, context: string): Promise<Answer[]> {
  2. if (question == null || context == null) {
  3. throw new Error(
  4. 'The input to findAnswers call is null, ' +
  5. 'please pass a string as input.');
  6. }
  7. const features =
  8. this.process(question, context, MAX_QUERY_LEN, MAX_SEQ_LEN);
  9. const inputIdArray = features.map(f => f.inputIds);
  10. const segmentIdArray = features.map(f => f.segmentIds);
  11. const inputMaskArray = features.map(f => f.inputMask);
  12. const globalStep = tf.scalar(1, 'int32');
  13. const batchSize = features.length;
  14. const result = tf.tidy(() => {
  15. const inputIds =
  16. tf.tensor2d(inputIdArray, [batchSize, INPUT_SIZE], 'int32');
  17. const segmentIds =
  18. tf.tensor2d(segmentIdArray, [batchSize, INPUT_SIZE], 'int32');
  19. const inputMask =
  20. tf.tensor2d(inputMaskArray, [batchSize, INPUT_SIZE], 'int32');
  21. return this.model.execute(
  22. {
  23. input_ids: inputIds,
  24. segment_ids: segmentIds,
  25. input_mask: inputMask,
  26. global_step: globalStep
  27. },
  28. ['start_logits', 'end_logits']) as [tf.Tensor2D, tf.Tensor2D];
  29. });
  30. const logits = await Promise.all([result[0].array(), result[1].array()]);
  31. //处理所有中间张量
  32. globalStep.dispose();
  33. result[0].dispose();
  34. result[1].dispose();
  35. const answers = [];
  36. for (let i = 0; i < batchSize; i++) {
  37. answers.push(this.getBestAnswers(
  38. logits[0][i], logits[1][i], features[i].origTokens,
  39. features[i].tokenToOrigMap, context, i));
  40. }
  41. return answers.reduce((flatten, array) => flatten.concat(array), [])
  42. .sort((logitA, logitB) => logitB.score - logitA.score)
  43. .slice(0, PREDICT_ANSWER_NUM);
  44. }

11.3.8  提取最佳答案

(1)通过如下代码从logits数组和输入中查找最佳的N个答案和logits。其中参数startologits表示开始索引答案,参数endLogits表示结束答案索引,参数origTokens表示通道的原始标记,参数tokenToOrigMap表示令牌到索引的映射。

  1. QuestionAndAnswerImpl.prototype.getBestAnswers = function (startLogits, endLogits, origTokens, tokenToOrigMap, context, docIndex) {
  2. var _a;
  3. if (docIndex === void 0) { docIndex = 0; }
  4. //模型使用封闭区间[开始,结束]作为索引
  5. var startIndexes = this.getBestIndex(startLogits);
  6. var endIndexes = this.getBestIndex(endLogits);
  7. var origResults = [];
  8. startIndexes.forEach(function (start) {
  9. endIndexes.forEach(function (end) {
  10. if (tokenToOrigMap[start] && tokenToOrigMap[end] && end >= start) {
  11. var length_2 = end - start + 1;
  12. if (length_2 < MAX_ANSWER_LEN) {
  13. origResults.push({ start: start, end: end, score: startLogits[start] + endLogits[end] });
  14. }
  15. }
  16. });
  17. });
  18. origResults.sort(function (a, b) { return b.score - a.score; });
  19. var answers = [];
  20. for (var i = 0; i < origResults.length; i++) {
  21. if (i >= PREDICT_ANSWER_NUM ||
  22. origResults[i].score < NO_ANSWER_THRESHOLD) {
  23. break;
  24. }
  25. var convertedText = '';
  26. var startIndex = 0;
  27. var endIndex = 0;
  28. if (origResults[i].start > 0) {
  29. _a = this.convertBack(origTokens, tokenToOrigMap, origResults[i].start, origResults[i].end, context), convertedText = _a[0], startIndex = _a[1], endIndex = _a[2];
  30. }
  31. else {
  32. convertedText = '';
  33. }
  34. answers.push({
  35. text: convertedText,
  36. score: origResults[i].score,
  37. startIndex: startIndex,
  38. endIndex: endIndex
  39. });
  40. }
  41. return answers;
  42. };

(2)编写函数getBestIndex(),功能是通过神经网络模型检索文章后,会找到多个答案,根据比率高低选出其中的5个最佳答案。代码如下:

  1.   getBestIndex(logits: number[]): number[] {
  2.     const tmpList = [];
  3.     for (let i = 0; i < MAX_SEQ_LEN; i++) {
  4.       tmpList.push([i, i, logits[i]]);
  5.     }
  6.     tmpList.sort((a, b) => b[2] - a[2]);
  7.     const indexes = [];
  8.     for (let i = 0; i < PREDICT_ANSWER_NUM; i++) {
  9.       indexes.push(tmpList[i][0]);
  10.     }
  11.     return indexes;
  12.   }

11.3.9  将答案转换回为文本

接下来使用convertBack()将问题的答案转换回原始文本形式,代码如下:

  1. convertBack(
  2. origTokens: Token[], tokenToOrigMap: {[key: string]: number},
  3. start: number, end: number, context: string): [string, number, number] {
  4. // 移位索引是:logits + offset.
  5. const shiftedStart = start + OUTPUT_OFFSET;
  6. const shiftedEnd = end + OUTPUT_OFFSET;
  7. const startIndex = tokenToOrigMap[shiftedStart];
  8. const endIndex = tokenToOrigMap[shiftedEnd];
  9. const startCharIndex = origTokens[startIndex].index;
  10. const endCharIndex = endIndex < origTokens.length - 1 ?
  11. origTokens[endIndex + 1].index - 1 :
  12. origTokens[endIndex].index + origTokens[endIndex].text.length;
  13. return [
  14. context.slice(startCharIndex, endCharIndex + 1).trim(), startCharIndex,
  15. endCharIndex
  16. ];
  17. }
  18. }

11.4  调试运行

到此为止,整个实例介绍完毕,接下来开始运行调试本项目。本项目基于Yarn 和Npm进行架构调试,其中Yarn对代码来说是一个包管理器,可以让我们使用并分享 全世界开发者的(例如 JavaScript)代码。运行调试本项目的基本流程如下:

(1)安装Node.js,然后打开Node.js命令行界面,输入如下命令来到项目的“qna”目录:

cd qna

(2)输入如下命令在“qna”目录中安装Npm:

npm install

(3)输入如下命令来到子目录“demo”:

cd qna/demo

(4)输入如下命令安装本项目需要的依赖项:

yarn

(5)输入如下命令编译依赖项:

yarn build-deps

(6)输入如下命令启动测试服务器,并监视文件的更改变化情况。

yarn watch

到目前为止,所有的编译运行工作全部完成,在笔者电脑中的整个编译过程如下:

  1. E:\123\lv\TensorFlow\daima\tfjs-models-master\qna>cd demo
  2. E:\123\lv\TensorFlow\daima\tfjs-models-master\qna\demo>yarn
  3. yarn install v1.22.10
  4. [1/5] Validating package.json...
  5. [2/5] Resolving packages...
  6. warning Resolution field "is-svg@4.3.1" is incompatible with requested version "is-svg@^3.0.0"
  7. success Already up-to-date.
  8. Done in 5.09s.
  9. E:\123\lv\TensorFlow\daima\tfjs-models-master\qna\demo>yarn build-deps
  10. yarn run v1.22.10
  11. $ yarn build-qna
  12. $ cd .. && yarn && yarn build-npm
  13. warning package-lock.json found. Your project contains lock files generated by tools other than Yarn. It is advised not to mix package managers in order to avoid resolution i
  14. nconsistencies caused by unsynchronized lock files. To clear this warning, remove package-lock.json.
  15. [1/4] Resolving packages...
  16. success Already up-to-date.
  17. $ yarn build && rollup -c
  18. $ rimraf dist && tsc
  19. src/index.ts → dist/qna.js...
  20. created dist/qna.js in 1m 18.9s
  21. src/index.ts → dist/qna.min.js...
  22. created dist/qna.min.js in 1m 1.3s
  23. src/index.ts → dist/qna.esm.js...
  24. created dist/qna.esm.js in 45.8s
  25. Done in 251.88s.
  26. E:\123\lv\TensorFlow\daima\tfjs-models-master\qna\demo>yarn watch
  27. yarn run v1.22.10
  28. $ cross-env NODE_ENV=development parcel index.html --no-hmr --open
  29. √ Built in 1.81s.

运行上述命令成功后自动打开一个网页http://localhost:1234/,在网页显示本项目的执行效果。执行后在表单中输入一个问题,这个问题的答案可以在表单上方的文章中找到。例如输入“Where was Tesla born”,然后单击“search”按钮,会自动输出显示这个问题的答案。如图11-2所示。

图11-2  执行效果

本项目的源码下载:https://download.csdn.net/download/asd343442/88968044

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/菜鸟追梦旅行/article/detail/309651
推荐阅读
相关标签
  

闽ICP备14008679号