├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app.js ├── images ├── background_2.webp ├── background_3.webp ├── fake-thumbnails.webp ├── image_1.webp ├── logo.webp └── no-cover.webp ├── index.js ├── index.min.js ├── js ├── DPlayer-1.26.0.edit.js ├── DPlayer-1.26.0.min.edit.js ├── jquery-3.5.1.min.js ├── markdown-it-12.0.4.min.js ├── mdui-1.0.1.min.js └── mdui.min.js.map └── src ├── app.js ├── index.js ├── style.css └── stylev2.css /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.ignoreWords": [ 3 | "NekoChan", 4 | "Pekora", 5 | "cdnjs", 6 | "chan", 7 | "dplayer", 8 | "gstatic", 9 | "hant", 10 | "jsdelivr", 11 | "markdownit", 12 | "mdui", 13 | "neko", 14 | "noto", 15 | "taiwan", 16 | "wght" 17 | ] 18 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 NekoChan 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Goindex 自用魔改版 2 | 3 | **fork from [yanzai/goindex](https://github.com/yanzai/goindex)** 4 | 5 | --- 6 | 7 | ![老婆圖](https://thumbs.gfycat.com/ImpeccableMisguidedIchthyostega-max-14mb.gif)
8 | ~~**老婆真可愛**~~ 9 | 10 | ## 預覽 11 | ![image1](https://i.imgur.com/0Jp32GQ.png) 12 | ![image2](https://i.imgur.com/N61YgfN.png) 13 | ![image3](https://i.imgur.com/EUUAMrH.png) 14 | 15 | ## 使用 & 功能 16 | 17 | - 本項目為個人修改使用,無考慮個人化的功能,**若有需求請自行修改。** 18 | 19 | #### 使用: 20 | 1. 複製最新的 [index.min.js](https://github.com/NekoChanTaiwan/NekoChan-Open-Data/blob/master/index.min.js)(有時候會更新,自己注意。)至你的 Worker。 21 | - 注意:如果確定要使用 Basic Auth 功能,請使用 [index.js](https://github.com/NekoChanTaiwan/NekoChan-Open-Data/blob/master/index.js)。 22 | 2. version 填入 [最新的min版本](https://github.com/NekoChanTaiwan/NekoChan-Open-Data/releases)(例如:2.0.0.min)。 23 | 3. client_id, client_secret, refresh_token 和其他 [Goindex項目](https://github.com/search?q=Goindex&type=Repositories) 取得方法相同。 24 | 25 | #### 資料夾封面: 26 | - 在該資料夾中放入檔名為 '封面.webp' 即可。
27 | (不帶引號,檔案不會渲染至列表中,也搜尋不到。默認只會預加載 完結、連載中 資料夾的封面。) 28 | 29 | #### 彩色資料夾: 30 | - 本項目設定為 完結(紅色)、連載中(黃色)。 31 | 32 | #### 播放器快捷鍵(PC): 33 | - **[ 上下方向鍵 ]**:加/減 音量 10% 34 | - **[ 左右方向鍵 ]**:退/進 進度 5 秒 35 | - **[ 數字鍵 ]**:影片進度跳轉(1=10%, 2=20%...) 36 | - **[ M ]**:靜音、解除靜音 37 | - **[ Z ]**:上一集 38 | - **[ X ]**:下一集 39 | - **[ F ]**:全螢幕、退出全螢幕 40 | 41 | ## 已知問題 42 | - ~~在安卓系統中,播放器無法正常載入~~ 43 | 44 | ## 更新內容 45 | 46 | ### 2.0.0 47 | - 修復導航條寬度不一致的問題 48 | - 在移動滾動條時,導航條會緊貼在上方 49 | - 導航條的網站名稱在寬度小於 980px 時隱藏,並改變搜尋欄的寬度至最寬 50 | - 增加導航條的搜尋欄寬度 51 | - 針對 MKV 格式隱藏播放器 52 | - 格式化、簡化 JS 53 | 54 | ### 1.9.9 55 | - 開啟 嚴格模式('use strict') 56 | - 修復 嚴格模式 檢測到的所有錯誤 57 | - 簡化 JS 58 | 59 | ### 1.9.8 60 | - 修復 在安卓系統無法正常載入播放器的問題 61 | 62 | ### 1.9.6 63 | - 修復 上下一集因檔名有時無法顯示的問題(感謝 Horis 協助) 64 | - 簡化 JS 65 | 66 | ### 1.9.5 67 | - 優化 根據預覽圖切換按紐的狀態 顯示播放器預覽圖的元素(之前關閉了,卻繼續顯示黑色元素) 68 | - 修復 DPlayer 快捷鍵 上下一集 問題 69 | - 新增 切換上下一集時 瀏覽器網址欄會隨著改變(代表F5刷新可以正常使用),但不需重新讀取頁面。 70 | 71 | ### 1.9.4 72 | - 優化 資料夾封面圖(實現同時預加載) 73 | - 修改 當滑鼠游標移動至資料夾上時,顯示資料夾封面圖。 74 | 75 | ### 1.9.3 76 | - 新增 搜尋列表支援資料夾封面圖 77 | - 修改 當滑鼠游標移動至資料夾上 1.5 -> 1 秒時,顯示資料夾封面圖。 78 | 79 | ### 1.9.2 80 | - 新增 當滑鼠游標移動至資料夾上 1.5 秒時,將顯示該資料夾的封面(如果有的話)。(封面檔案不會被搜尋到,以及渲染出列表中。) 81 | 82 | ### 1.9.1 83 | - 修復 搜尋列表 資料夾不會渲染成特殊顏色的問題(例如:完結、連載) 84 | - 修復 移動端 顯示進度條預覽圖切換按紐的問題(原本不應該顯示) 85 | - 簡化 JS 86 | 87 | ### 1.9.0 88 | - 新增 進度條預覽圖 切換按鈕(預設關閉) 89 | - 隱藏 !head.md, !readme.md 檔案(可搜尋到) 90 | - 修改 項目排序 91 | 92 | ### 1.8.9 93 | - 新增 DPlayer 快捷鍵 94 | - 數字鍵(包括上排):影片進度跳轉(1=10%, 2=20%...), 95 | - M:靜音(解除靜音), 96 | - Z:上一集, 97 | - X:下一集, 98 | - F:全螢幕(退出全螢幕) 99 | - 當前路徑不會顯示檔案,只有資料夾 100 | - 簡化 JS 101 | 102 | ### 1.8.8 103 | - 更新 MDUI 排版、CSS 104 | - 移除 修改時間 105 | - 移除 不必要的 JS 函式、HTML 元素 106 | - 壓縮 JS Worker 107 | 108 | ### 1.8.7 109 | - DPlayer 進度條預覽圖 將只在 WIN、MAC 系統下開啟。(考慮到手機或平板用戶的網速) 110 | 111 | ### 1.8.5 112 | - 優化 DPlayer 進度條預覽圖 113 | 114 | ### 1.8.4 115 | - 新增 DPlayer 進度條預覽圖(感謝 Horis、mp0530 參與) 116 | 117 | (由於是及時獲取畫面,並不像其他影音平台在上傳時就處裡好的。所以還有可優化的空間。) 118 | 119 | ### 1.8.2 120 | - 更換 背景圖片 121 | - 以顏色區分 連載中、完結、R18 項目 122 | 123 | ### 1.8.1 124 | - 新增 返回頂部 按鈕 125 | - 修正 檔案順序 不忽略資料夾的問題 126 | - 升級 DPlayer 1.26.0 127 | 128 | ### 1.8.0 129 | - 關閉 搜尋欄提示內容 130 | 131 | ### 1.7.9 132 | - 新增 日文字體 133 | - 簡化 JS、CSS 134 | - 修改 每次讀取項目量 999 -> 100 135 | 136 | ### 1.7.8 137 | - 新增 搜尋欄 138 | - 移除 網址時間戳 139 | - 修復 列表有時為空的問題(狀態碼 500) 140 | 141 | ### 1.7.2 142 | - 在檔案圖標前方新增順序(不包括資料夾) 143 | 144 | ### 1.6.8 145 | - 網址後方新增時間戳,確保當前頁面為最新資料 146 | - 簡化JS 147 | 148 | ### 1.6.3.1 149 | - 更新 背景圖片 150 | - 新增 連結預覽圖片 151 | 152 | ### 1.6.1 153 | - 新增 Twitter 按鈕 154 | - 新增 重新整理 按鈕 155 | 156 | ### 1.5.7 157 | - 新增 DPlayer 載入失敗或跳轉 發生載入失敗時,自動重新讀取並跳轉至 上一次的播放時間 或 正在跳轉時間 或 已跳轉的時間 158 | 159 | ### 1.5.1 160 | - 修復 DPlayer 有時無法正常載入 問題 161 | 162 | ### 1.4.7 163 | - 改寫了部分語法、以 ES6 語法壓縮檔案 164 | - 新增 網站 icon 165 | - 移除 Twitter 按鈕 166 | - 移除 Donate 按鈕 167 | - 移除 notyf 通知 168 | - 移除 自動切換下一集 169 | - 更新 jQuery 3.5.1 170 | - 更新 mdui 1.0.1 171 | - 更新 markdown-it 12.0.4 172 | - 更新 DPlayer 1.25.1 173 | - 修復 DPlayer影片載入失敗 問題 174 | - 修復 console 警告的錯誤 175 | 176 | ### 1.1.7 177 | - 新增 notyf 通知 178 | 179 | ### 1.0.7 180 | - 影片結束後自動切換下一集(如果有的話) 181 | - 啟用 Dplayer 截圖功能 182 | 183 | ### 1.0.0 184 | - 新增 自訂背景 185 | - 新增 支援繁體中文字體 186 | - 新增 [Dplayer](https://github.com/MoePlayer/DPlayer) 播放器 [v1.25.1](https://github.com/MoePlayer/DPlayer/releases/tag/v1.25.1) 187 | - 支援 各裝置的 播放器串流 [PotPlayer(Win)](https://potplayer.daum.net/?lang=zh_TW)、[IINA(Mac)](https://iina.io/)、[MX Player(Android)](https://play.google.com/store/apps/details?id=com.mxtech.videoplayer.ad)、[Infuse(iOS)](https://apps.apple.com/tw/app/infuse-6/id1136220934) 188 | 189 | ## 聯絡 190 | **Discord:NekoChan#2851** 191 | 192 | ## License 193 | [MIT](LICENSE) 194 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | "use strict";const Os={isWindows:navigator.platform.toUpperCase().includes("WIN"),isMac:navigator.platform.toUpperCase().includes("MAC"),isMacLike:/(Mac|iPhone|iPod|iPad)/i.test(navigator.platform),isIos:/(iPhone|iPod|iPad)/i.test(navigator.platform),isMobile:/Android|webOS|iPhone|iPad|iPod|iOS|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)};let imageUrls=[];function preloadImages(t){for(let e=0,i=t.length;e{}),window[`index_${e}`].crossOrigin="",window[`index_${e}`].src=t[e]}function init(){document.siteName=$("title").html(),$("body").addClass(`mdui-theme-primary-${UI.main_color} mdui-theme-accent-${UI.accent_color}`);$("body").html('\n\t\n\t
\n\t
\n\t
\n\t\t
\n\t\t\t\n\t\t
\n\t
'),/(WIN|Mac)/i.test(navigator.userAgent)&&$(()=>{const t=$("#folderIMGElement");t.hide(),$(document).mousemove(e=>{t.css({left:`${e.pageX+25}px`,top:`${e.pageY+25}px`})}),$(window).on("scroll",function(){t.hide()})}),$(window).on("scroll",function(){$(window).scrollTop()>0?$("#nav").css({position:"fixed"}):0===$(window).scrollTop()&&$("#nav").css({position:"static"})})}function getDocumentHeight(){let t=document;return Math.max(t.body.scrollHeight,t.documentElement.scrollHeight,t.body.offsetHeight,t.documentElement.offsetHeight,t.body.clientHeight,t.documentElement.clientHeight)}function render(t){t.indexOf("?")>0&&(t=t.substr(0,t.indexOf("?"))),title(t),nav(t);window.MODEL.is_search_page?(window.scroll_status={event_bound:!1,loading_lock:!1},render_search_result_list()):t.match(/\/\d+:$/g)||"/"==t.substr(-1)?(window.scroll_status={event_bound:!1,loading_lock:!1},list(t)):file(t)}function title(t){t=decodeURI(t);let e=window.current_drive_order||0,i=window.drive_names[e],n=window.MODEL;t=t.replace(`/${e}:`,""),$("title").html(`${document.siteName} - ${t}`),n.is_search_page?$("title").html(`${document.siteName} - ${i} - 搜尋 ${n.q} 的結果`):$("title").html(`${document.siteName} - ${i} - ${t}`),$("title").html(`${document.siteName}`)}function nav(t){let e=window.MODEL,i="",n=window.current_drive_order||0;i+=`${document.siteName}`;let l=`當前位置: 主目錄`;if(!e.is_search_page){let e=t.trim("/").split("/"),i="/";if(e.length>1){e.shift();for(let t in e){let a=e[t];if(i+=`${a=decodeURI(a)}/`,""==a||/md|mp4|webm|avi|bmp|jpg|jpeg|png|gif|m4a|mp3|flac|wav|ogg|mpg|mpeg|mkv|rm|rmvb|mov|wmv|asf|ts|flv/.test(a))break;l+=`chevron_right${a}`}}}$("#folderPath").html(l),i+=`
\n\t\t`,$("#nav").html(i),mdui.mutation(),mdui.updateTextFields()}function requestListPath(t,e,i,n){let l={password:e.password||null,page_token:e.page_token||null,page_index:e.page_index||0};$.post(t,l,(e,a)=>{let d=jQuery.parseJSON(e);d&&d.error&&"401"==d.error.code?n&&n(t):d&&d.data&&i&&i(d,t,l)})}function requestSearch(t,e){let i={q:t.q||null,page_token:t.page_token||null,page_index:t.page_index||0};$.post(`/${window.current_drive_order}:search`,i,(t,n)=>{let l=jQuery.parseJSON(t);l&&l.data&&e&&e(l,i)})}function list(t){let e=null;$("#content").html('\n\t\n\t\t
\n\t\t\t\n\t
\n\t
\n\t\t\n\t\t

Discord:NekoChan#2851
返回頂部
\n\t
\n\t');let i=localStorage.getItem(`password${t}`);$("#list").html('
'),$("#readme_md").hide().html(""),$("#head_md").hide().html(""),requestListPath(t,{password:i},function t(i,n,l){$("#list").data("nextPageToken",i.nextPageToken).data("curPageIndex",i.curPageIndex),$("#spinner").remove(),null===i.nextPageToken?($(window).off("scroll"),window.scroll_status.event_bound=!1,window.scroll_status.loading_lock=!1,append_files_to_list(n,i.data.files),preloadImages(imageUrls),$(".clickFolder").hover(function(){e=`${this.querySelector("a.folder").href}封面.webp`,$("#folderIMGElementSrc").attr("src",e),$("#folderIMGElement").show()},()=>{$("#folderIMGElementSrc").attr("src",""),$("#folderIMGElement").hide()})):(append_files_to_list(n,i.data.files),preloadImages(imageUrls),$(".clickFolder").hover(function(){e=`${this.querySelector("a.folder").href}封面.webp`,$("#folderIMGElementSrc").attr("src",e),$("#folderIMGElement").show()},()=>{$("#folderIMGElementSrc").attr("src",""),$("#folderIMGElement").hide()}),!0!==window.scroll_status.event_bound&&($(window).on("scroll",function(){let e=$(this).scrollTop(),i=getDocumentHeight();if(e+$(this).height()>i-(Os.isMobile?130:80)){if(!0===window.scroll_status.loading_lock)return;window.scroll_status.loading_lock=!0,$('
').insertBefore("#readme_md"),mdui.updateSpinners();let e=$("#list");requestListPath(n,{password:l.password,page_token:e.data("nextPageToken"),page_index:e.data("curPageIndex")+1},t,null)}}),window.scroll_status.event_bound=!0)),!0===window.scroll_status.loading_lock&&(window.scroll_status.loading_lock=!1)},t=>{$("#spinner").remove();let e=prompt("目錄加密, 請輸入密碼","");localStorage.setItem(`password${t}`,e),null!=e&&""!=e?list(t):history.go(-1)})}function append_files_to_list(t,e){let i=$("#list"),n=null===i.data("nextPageToken"),l="0"==i.data("curPageIndex"),a=0,d="",o=[],s="";for(let i in e){let l=e[i],r=`${t+l.name}/`;if(null==l.size&&(l.size=""),l.size=formatFileSize(l.size),"application/vnd.google-apps.folder"==l.mimeType)/連載中/.test(l.name)?(s="updating",imageUrls.push(`${r}%E5%B0%81%E9%9D%A2.webp`)):/完結/.test(l.name)?(s="finish",imageUrls.push(`${r}%E5%B0%81%E9%9D%A2.webp`)):s=/R18/.test(l.name)?"r18":"",d+=`
  • \n\t\t\t\t
    folder_open${l.name}
    \n\t\t\t\t
    ${l.size}
    \n\t\t\t\t
    \n\t\t\t
  • `;else{let e=t+l.name,i="file";const s=t+l.name;if(n&&"!readme.md"==l.name){get_file(e,l,t=>{markdown("#readme_md",t)});continue}if("!head.md"==l.name){get_file(e,l,t=>{markdown("#head_md",t)});continue}switch(l.name){case"封面.webp":continue}let r=e.split(".").pop().toLowerCase();"|html|php|css|go|java|js|json|txt|sh|md|mp4|webm|avi|bmp|jpg|jpeg|png|gif|m4a|mp3|flac|wav|ogg|mpg|mpeg|mkv|rm|rmvb|mov|wmv|asf|ts|flv|pdf|".includes(`|${r}|`)&&(o.push(s),a++,e+="?a=view",i+=" view"),d+=`
  • \n\t\t\t\t
    ${a}.insert_drive_file${l.name}
    \n\t\t\t\t
    ${l.size}
    \n\t\t\t\t
    \n\t\t\t
  • `}}if(o.length>0){let e=localStorage.getItem(t),i=o;if(!l&&e){let t;try{t=JSON.parse(e),Array.isArray(t)||(t=[])}catch(e){t=[]}i=t.concat(o)}localStorage.setItem(t,JSON.stringify(i))}i.html(("0"==i.data("curPageIndex")?"":i.html())+d),n&&$("#count").removeClass("mdui-hidden").find(".number").text(i.find("li.mdui-list-item").length)}function render_search_result_list(){let t=null,e=window.current_drive_order;$("#content").html('\n\t\n\t\t
    \n\t\t\t\n\t\t
    \n\t
    \n\t\n\t

    Discord:NekoChan#2851
    返回頂部
    \n\t
    \n\t'),$("#list").html('
    '),$("#readme_md").hide().html(""),$("#head_md").hide().html(""),requestSearch({q:window.MODEL.q},function i(n,l){$("#list").data("nextPageToken",n.nextPageToken).data("curPageIndex",n.curPageIndex),$("#spinner").remove(),null===n.nextPageToken?($(window).off("scroll"),window.scroll_status.event_bound=!1,window.scroll_status.loading_lock=!1,append_search_result_to_list(n.data.files),$(".clickFolder").hover(function(){$.post(`/${e}:id2path`,{id:this.querySelector("a.folder").id},i=>{i&&(t=`/${e}:${i}封面.webp`,$("#folderIMGElementSrc").attr("src",t),$("#folderIMGElement").show())})},()=>{$("#folderIMGElementSrc").attr("src",""),$("#folderIMGElement").hide()})):(append_search_result_to_list(n.data.files),$(".clickFolder").hover(function(){$.post(`/${e}:id2path`,{id:this.querySelector("a.folder").id},i=>{i&&(t=`/${e}:${i}封面.webp`,$("#folderIMGElementSrc").attr("src",t),$("#folderIMGElement").show())})},()=>{$("#folderIMGElementSrc").attr("src",""),$("#folderIMGElement").hide()}),!0!==window.scroll_status.event_bound&&($(window).on("scroll",function(){let t=$(this).scrollTop(),e=getDocumentHeight();if(t+$(this).height()>e-(Os.isMobile?130:80)){if(!0===window.scroll_status.loading_lock)return;window.scroll_status.loading_lock=!0,$('
    ').insertBefore("#readme_md"),mdui.updateSpinners();let t=$("#list");requestSearch({q:window.MODEL.q,page_token:t.data("nextPageToken"),page_index:t.data("curPageIndex")+1},i)}}),window.scroll_status.event_bound=!0)),!0===window.scroll_status.loading_lock&&(window.scroll_status.loading_lock=!1)})}function append_search_result_to_list(t){let e=$("#list"),i=null===e.data("nextPageToken"),n="",l="";for(let e in t){let i=t[e];if(null==i.size&&(i.size=""),i.size=formatFileSize(i.size),"application/vnd.google-apps.folder"==i.mimeType)l=/連載中/.test(i.name)?"updating":/完結/.test(i.name)?"finish":/R18/.test(i.name)?"r18":"",n+=`
  • \n\t\t\t\t\t
    folder_open${i.name}
    \n\t\t\t\t\t
    ${i.size}
    \n\t\t\t\t
    \n\t\t\t
  • `;else{let t="file",e=i.name.split(".").pop().toLowerCase();switch(i.name){case"!head.md":case"封面.webp":continue}"|html|php|css|go|java|js|json|txt|sh|md|mp4|webm|avi|bmp|jpg|jpeg|png|gif|m4a|mp3|flac|wav|ogg|mpg|mpeg|mkv|rm|rmvb|mov|wmv|asf|ts|flv|".includes(`|${e}|`)&&(t+=" view"),n+=`
  • \n\t\t\t\t\t
    insert_drive_file${i.name}
    \n\t\t\t\t\t
    ${i.size}
    \n\t\t\t\t
    \n\t\t
  • `}}e.html(("0"==e.data("curPageIndex")?"":e.html())+n),i&&$("#count").removeClass("mdui-hidden").find(".number").text(e.find("li.mdui-list-item").length)}function onSearchResultItemClick(t){let e=$(t).hasClass("view"),i=window.current_drive_order,n=mdui.dialog({title:"",content:'
    正在獲取路徑...
    ',history:!1,modal:!0,closeOnEsc:!0});mdui.updateSpinners(),$.post(`/${i}:id2path`,{id:t.id},t=>{if(t)return n.close(),void(window.location.href=`/${i}:${t}${e?"?a=view":""}`);n.close(),n=mdui.dialog({title:"獲取目標路徑失敗",content:"該資源可能已經移除,或已移動,請通知 NekoChan#2851 解決。",history:!1,modal:!0,closeOnEsc:!0,buttons:[{text:"確認"}]})})}function get_file(t,e,i){let n=`file_path_${t}`,l=localStorage.getItem(n);if(null!=l)return i(l);$.get(t,t=>{localStorage.setItem(n,t),i(t)})}function file(t){let e=t.split("/").pop().split(".").pop().toLowerCase().replace("?a=view","");"|mkv|".includes(`|${e}|`)&&file_mkv(t),"|mp4|webm|avi|mpg|mpeg|rm|rmvb|mov|wmv|asf|ts|flv|".includes(`|${e}|`)&&file_video(t),"|bmp|jpg|jpeg|png|gif|".includes(`|${e}|`)&&file_image(t)}function file_mkv(t){const e="mdui-btn mdui-btn-raised mdui-ripple mdui-color-theme-accent",i=decodeURI(t.slice(t.lastIndexOf("/")+1,t.length));let n=decodeURI(window.location.origin+t),l="";if(/(WIN)/i.test(navigator.userAgent))l=`PotPlayer 串流`;else if(/(Mac)/i.test(navigator.userAgent))l=``;else if(/(Android)/i.test(navigator.userAgent))l=``,l+=`
    `;else if(/(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent)){l=`Infuse 串流`}let a=`\n\t
    \n\t\t
    \n\t\t\t\n\t\t\t\n\t\t
    \n\t
    \n\t
    \n\t${l+=`
    直連下載檔案`}\n\t
    \n\t\t\n\t
    \n\t
    \n\t`;$("#content").html(a)}function file_video(t){let e=decodeURI(window.location.origin+t),i="";const n=decodeURI(t.slice(t.lastIndexOf("/")+1,t.length)),l=window.location.pathname,a=l.lastIndexOf("/"),d=l.slice(0,a+1);let o=localStorage.getItem(d);if(o){try{o=JSON.parse(o),Array.isArray(o)||(o=[])}catch(t){console.error(t),o=[]}if(o.length>0&&o.includes(d+n)){let t=o.length,e=o.indexOf(d+n),l=e-1>-1?o[e-1]:null,a=e+1\n\t\t\t\t
    \n\t\t\t\t\t
    \n\t\t\t\t\t\t${l?``:``}\n\t\t\t\t\t
    \n\t\t\t\t\t
    \n\t\t\t\t\t\t${a?``:``}\n\t\t\t\t\t
    \n\t\t\t\t
    \n\t\t\t\n\t\t\t`}}const s="mdui-btn mdui-btn-raised mdui-ripple mdui-color-theme-accent";let r=`PotPlayer 串流`,c="",m="",u="";if(Os.isMobile){if(u=`\n\t\t\n\t\t`,/(Android)/i.test(navigator.userAgent))r=``,r+=`
    `;else if(/(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent)){r=`Infuse 串流`}}else u='\n\t\t
    \n\t\t
    \n\t\t',/(Mac)/i.test(navigator.userAgent)&&(r=``),null==localStorage.getItem("previewSwitch")&&localStorage.setItem("previewSwitch","false"),"false"==localStorage.getItem("previewSwitch")?m='':"true"==localStorage.getItem("previewSwitch")&&(m=''),c=`\n\t\t\n\t\t\tondemand_video\n\t\t\t進度條預覽圖\n\t\t\t\n\t\t\n\t\t`;let p=`\n\t
    \n\t\t
    \n\t\t\t\n\t\t\t\n\t\t
    \n\t\t${u}\n\t\t
    \n\t\t${i}\n\t
    \n\t
    \n\t${r+=`
    直連下載檔案`}\n\t${c}\n\t
    \n\t\t\n\t
    \n\t
    \n\t`;$("#content").html(p),/(WIN|Mac)/i.test(navigator.userAgent)&&$(()=>{window.DPlayer||window.location.reload(),$("#previewSwitch").click(()=>{"true"==localStorage.getItem("previewSwitch")?(localStorage.setItem("previewSwitch","false"),window.location.reload()):"false"==localStorage.getItem("previewSwitch")&&(localStorage.setItem("previewSwitch","true"),window.location.reload())});const t=()=>{let i=0,n=.5,l=!1,a=null;"true"==localStorage.getItem("previewSwitch")?a=new DPlayer({container:$("#player")[0],theme:"#0080ff",autoplay:!0,lang:"zh-tw",mutex:!1,volume:.5,video:{url:e,thumbnails:"//cdn.jsdelivr.net/gh/NekoChanTaiwan/NekoChan-Open-Data@1.8.6.beta2/images/fake-thumbnails.webp"}}):"false"==localStorage.getItem("previewSwitch")&&(a=new DPlayer({container:$("#player")[0],theme:"#0080ff",autoplay:!0,lang:"zh-tw",mutex:!1,volume:.5,video:{url:e}})),0!=i&&a.seek(i),a.on("seeked",()=>{i=a.video.currentTime}),a.on("seeking",()=>{i=a.video.currentTime}),a.on("error",()=>{0!=a.video.currentTime&&(i=a.video.currentTime),t()}),a.on("ended",()=>{a.fullScreen.cancel("browser")}),a.on("loadedmetadata",()=>{const t=a.video.duration/10;$(window).unbind("keyup"),$(window).keyup(e=>{if(/Numpad/.test(e.code)){let i=Number(e.code[6]);a.seek(t*i)}else if(/Digit/.test(e.code)){let i=Number(e.code[5]);a.seek(t*i)}else if(/Key/.test(e.code))switch(e.code[3]){case"M":if(0==l){d(),a.volume(0,!0,!1),l=!0;break}if(1==l){a.volume(n,!0,!1),l=!1;break}case"X":$("#rightBtn").click();break;case"Z":$("#leftBtn").click();break;case"F":$(".dplayer-icon.dplayer-full-icon").click()}})});const d=()=>{let t=`${String($(".dplayer-volume-bar-wrap").attr("data-balloon"))}`;"%"==t[3]?n=1:"%"==t[2]?n=Number(`0.${t[0]}`):"%"==t[1]&&(n=.1)}};t();const i=()=>{let t=0,n=0,l=null,a=$("#player canvas");const d=$("#screenshotPlayer")[0],o=$("#player .dplayer-bar-wrap"),s=$("#player .dplayer-bar-preview");d.style.display="none";let r=null;(r=new DPlayer({container:d,autoplay:!0,screenshot:!0,mutex:!1,video:{url:e}})).volume(0,!0,!0),r.speed(16);let c=()=>{(t>n+2||tn+5||t{(e=>{l=e.split(":"),2===e.length?t=Number(l):5===e.length?t=60*Number(l[0])+Number(l[1]):8===e.length&&(t=3600*Number(l[0])+60*Number(l[1])+Number(l[2])),c()})($(".dplayer-bar-time").html())}),r.on("error",()=>{i()})};"true"==localStorage.getItem("previewSwitch")&&i()}),$("#leftBtn, #rightBtn").click(t=>{let e=$(t.target);["I","SPAN"].includes(t.target.nodeName)&&(e=$(t.target).parent());const i=e.attr("data-filepath");history.pushState({},"",`${i}?a=view`),file(i)})}function file_image(t){let e=`
    \n\t
    \n\t\n\t
    \n\t
    \n\t
    `;$("#content").html(e)}function formatFileSize(t){return t=t>=1073741824?`${(t/1073741824).toFixed(2)} GB`:t>=1048576?`${(t/1048576).toFixed(2)} MB`:t>=1024?`${(t/1024).toFixed(2)} KB`:t>1?`${t} Bytes`:1==t?`${t} Byte`:" 資料夾"}function markdown(t,e){if(window.markdownit||window.location.reload(),null==window.md)window.md=window.markdownit(),markdown(t,e);else{let i=md.render(e);$(t).show().html(i)}}String.prototype.trim=function(t){return t?this.replace(new RegExp(`^\\${t}+|\\${t}+$`,"g"),""):this.replace(/^\s+|\s+$/g,"")},window.onpopstate=(()=>{render(window.location.pathname)}),$(()=>{init(),render(window.location.pathname)}); -------------------------------------------------------------------------------- /images/background_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekochan0122/NekoChan-Open-Data/4ecbc863086fae018b6a6d97b1b4e16f7b5ca1d4/images/background_2.webp -------------------------------------------------------------------------------- /images/background_3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekochan0122/NekoChan-Open-Data/4ecbc863086fae018b6a6d97b1b4e16f7b5ca1d4/images/background_3.webp -------------------------------------------------------------------------------- /images/fake-thumbnails.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekochan0122/NekoChan-Open-Data/4ecbc863086fae018b6a6d97b1b4e16f7b5ca1d4/images/fake-thumbnails.webp -------------------------------------------------------------------------------- /images/image_1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekochan0122/NekoChan-Open-Data/4ecbc863086fae018b6a6d97b1b4e16f7b5ca1d4/images/image_1.webp -------------------------------------------------------------------------------- /images/logo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekochan0122/NekoChan-Open-Data/4ecbc863086fae018b6a6d97b1b4e16f7b5ca1d4/images/logo.webp -------------------------------------------------------------------------------- /images/no-cover.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nekochan0122/NekoChan-Open-Data/4ecbc863086fae018b6a6d97b1b4e16f7b5ca1d4/images/no-cover.webp -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const authConfig = { 2 | siteName: 'NekoChan Open Data', 3 | version: '', 4 | client_id: '', 5 | client_secret: '', 6 | refresh_token: '', 7 | /** 8 | * 設置要顯示的多個雲端硬碟;按格式添加多個 9 | * [id]: 可以是 團隊盤id、子文件夾id、或者"root"(代表個人盤根目錄) 10 | * [name]: 顯示的名稱 11 | * [user]: Basic Auth 的使用者名稱 12 | * [pass]: Basic Auth 的密碼 13 | * [protect_file_link]: Basic Auth 是否用於保護文件連結,預設值(不設置時)為 false,即不保護文件連結(方便 直鏈下載/外部播放 等) 14 | * 每個盤的 Basic Auth 都可以單獨設置。Basic Auth 默認保護該盤下所有文件夾/子文件夾路徑 15 | * 【注意】默認不保護文件連結,這樣可以方便 直鏈下載/外部播放 16 | * 如果要保護文件連結,需要將 protect_file_link 設置為 true,此時如果要進行外部播放等操作,需要將 host 替換為 user:pass@host 的 形式 17 | * 不需要 Basic Auth 的盤,保持 user 和 pass 同時為空即可。(直接不設置也可以) 18 | * 【注意】對於id設置為為子文件夾id的盤將不支持搜尋功能(不影響其他盤)。 19 | */ 20 | roots: [ 21 | { 22 | id: 'root', 23 | name: '主目錄', 24 | user: '', 25 | pass: '' 26 | }, 27 | ], 28 | /** 29 | * 文件列表頁面每頁顯示的數量。【推薦設置值為 100 到 1000 之間】; 30 | * 如果設置大於1000,會導致請求 drive api 時出錯; 31 | * 如果設置的值過小,會導致文件列表頁面滾動條增量載入(分頁載入)失效; 32 | * 此值的另一個作用是,如果目錄內文件數大於此設置值(即需要多頁展示的),將會對首次列目錄結果進行快取。 33 | */ 34 | files_list_page_size: 50, 35 | /** 36 | * 搜索結果頁面每頁顯示的數量。【推薦設置值為 50 到 1000 之間】; 37 | * 如果設置大於1000,會導致請求 drive api 時出錯; 38 | * 如果設置的值過小,會導致搜索結果頁面滾動條增量載入(分頁載入)失效; 39 | * 此值的大小影響搜索操作的響應速度。 40 | */ 41 | search_result_list_page_size: 50, 42 | // 確認有 cors 用途的可以開啟 43 | enable_cors_file_down: false, 44 | /** 45 | * 上面的 basic auth 已經包含了盤內全局保護的功能。所以默認不再去認證 .password 文件內的密碼 46 | * 如果在全局認證的基礎上,仍需要給某些目錄單獨進行 .password 文件內的密碼驗證的話,將此选项設置為 true 47 | * 【注意】如果開啟了 .password 文件密碼驗證,每次列目錄都會額外增加查詢目錄內 .password 文件是否存在的開銷。 48 | */ 49 | enable_password_file_verify: false, 50 | } 51 | 52 | /** 53 | * web ui 設置 54 | */ 55 | const uiConfig = { 56 | main_color: 'blue', 57 | accent_color: 'blue', 58 | } 59 | 60 | /** 61 | * global functions 62 | */ 63 | const FUNCS = { 64 | /** 65 | * 轉換成針對Google搜索詞法相對安全的搜索關鍵字 66 | */ 67 | formatSearchKeyword: function (keyword) { 68 | let nothing = '' 69 | let space = ' ' 70 | if (!keyword) return nothing 71 | return keyword 72 | .replace(/(!=)|['"=<>/\\:]/g, nothing) 73 | .replace(/[,,|(){}]/g, space) 74 | .trim() 75 | }, 76 | } 77 | 78 | /** 79 | * global consts 80 | * @type {{folder_mime_type: string, default_file_fields: string, gd_root_type: {share_drive: number, user_drive: number, sub_folder: number}}} 81 | */ 82 | const CONSTS = new (class { 83 | default_file_fields = 84 | 'parents,id,name,mimeType,modifiedTime,createdTime,fileExtension,size' 85 | gd_root_type = { 86 | user_drive: 0, 87 | share_drive: 1, 88 | sub_folder: 2, 89 | } 90 | folder_mime_type = 'application/vnd.google-apps.folder' 91 | })() 92 | 93 | // gd instances 94 | var gds = [] 95 | 96 | function html(current_drive_order = 0, model = {}) { 97 | return ` 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | ${authConfig.siteName} 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 127 | 128 | 129 | 130 | 131 | 132 | ` 133 | } 134 | 135 | addEventListener('fetch', (e) => { 136 | e.respondWith(handleRequest(e.request)) 137 | }) 138 | 139 | /** 140 | * Fetch and log a request 141 | * @param {Request} request 142 | */ 143 | async function handleRequest(request) { 144 | if (gds.length === 0) { 145 | for (let i = 0; i < authConfig.roots.length; i++) { 146 | const gd = new googleDrive(authConfig, i) 147 | await gd.init() 148 | gds.push(gd) 149 | } 150 | // 這個操作並行,提高效率 151 | let tasks = [] 152 | gds.forEach((gd) => { 153 | tasks.push(gd.initRootType()) 154 | }) 155 | for (let task of tasks) { 156 | await task 157 | } 158 | } 159 | 160 | // 從 path 中提取 drive order 161 | // 並根據 drive order 獲取對應的 gd instance 162 | let gd, 163 | url = new URL(request.url), 164 | path = url.pathname 165 | 166 | /** 167 | * 重定向至起始頁 168 | * @returns {Response} 169 | */ 170 | function redirectToIndexPage() { 171 | return new Response('', { 172 | status: 301, 173 | headers: { Location: `${url.origin}/0:/` }, 174 | }) 175 | } 176 | 177 | if (path == '/') return redirectToIndexPage() 178 | if (path.toLowerCase() == '/favicon.ico') { 179 | // 後面可以找一個 favicon 180 | return new Response('', { status: 404 }) 181 | } 182 | 183 | // 特殊命令格式 184 | const command_reg = /^\/(?\d+):(?[a-zA-Z0-9]+)$/g 185 | const match = command_reg.exec(path) 186 | if (match) { 187 | const num = match.groups.num 188 | const order = Number(num) 189 | if (order >= 0 && order < gds.length) { 190 | gd = gds[order] 191 | } else { 192 | return redirectToIndexPage() 193 | } 194 | // basic auth 195 | for (const r = gd.basicAuthResponse(request); r; ) return r 196 | const command = match.groups.command 197 | // 搜索 198 | if (command === 'search') { 199 | if (request.method === 'POST') { 200 | // 搜索結果 201 | return handleSearch(request, gd) 202 | } else { 203 | const params = url.searchParams 204 | // 搜索頁面 205 | return new Response( 206 | html(gd.order, { 207 | q: params.get('q') || '', 208 | is_search_page: true, 209 | root_type: gd.root_type, 210 | }), 211 | { 212 | status: 200, 213 | headers: { 'Content-Type': 'text/html; charset=utf-8' }, 214 | } 215 | ) 216 | } 217 | } else if (command === 'id2path' && request.method === 'POST') { 218 | return handleId2Path(request, gd) 219 | } 220 | } 221 | 222 | // 期望的 path 格式 223 | const common_reg = /^\/\d+:\/.*$/g 224 | try { 225 | if (!path.match(common_reg)) { 226 | return redirectToIndexPage() 227 | } 228 | let split = path.split('/'), 229 | order = Number(split[1].slice(0, -1)) 230 | if (order >= 0 && order < gds.length) { 231 | gd = gds[order] 232 | } else { 233 | return redirectToIndexPage() 234 | } 235 | } catch (e) { 236 | return redirectToIndexPage() 237 | } 238 | 239 | // basic auth 240 | // for (const r = gd.basicAuthResponse(request); r;) return r; 241 | const basic_auth_res = gd.basicAuthResponse(request) 242 | 243 | path = path.replace(gd.url_path_prefix, '') || '/' 244 | if (request.method == 'POST') { 245 | return basic_auth_res || apiRequest(request, gd) 246 | } 247 | 248 | let action = url.searchParams.get('a') 249 | 250 | if (path.substr(-1) == '/' || action != null) { 251 | return ( 252 | basic_auth_res || 253 | new Response(html(gd.order, { root_type: gd.root_type }), { 254 | status: 200, 255 | headers: { 'Content-Type': 'text/html; charset=utf-8' }, 256 | }) 257 | ) 258 | } else { 259 | if (path.split('/').pop().toLowerCase() == '.password') { 260 | return basic_auth_res || new Response('', { status: 404 }) 261 | } 262 | let file = await gd.file(path) 263 | let range = request.headers.get('Range') 264 | const inline_down = 'true' === url.searchParams.get('inline') 265 | if (gd.root.protect_file_link && basic_auth_res) return basic_auth_res 266 | return gd.down(file.id, range, inline_down) 267 | } 268 | } 269 | 270 | async function apiRequest(request, gd) { 271 | let url = new URL(request.url) 272 | let path = url.pathname 273 | path = path.replace(gd.url_path_prefix, '') || '/' 274 | 275 | let option = { status: 200, headers: { 'Access-Control-Allow-Origin': '*' } } 276 | 277 | if (path.substr(-1) == '/') { 278 | let form = await request.formData() 279 | // 這樣可以提升首次列目錄時的速度。缺點是,如果password驗證失敗,也依然會產生列目錄的開銷 280 | let deferred_list_result = gd.list( 281 | path, 282 | form.get('page_token'), 283 | Number(form.get('page_index')) 284 | ) 285 | 286 | // check .password file, if `enable_password_file_verify` is true 287 | if (authConfig['enable_password_file_verify']) { 288 | let password = await gd.password(path) 289 | // console.log("dir password", password); 290 | if (password && password.replace('\n', '') !== form.get('password')) { 291 | let html = `{"error": {"code": 401,"message": "password error."}}` 292 | return new Response(html, option) 293 | } 294 | } 295 | 296 | let list_result = await deferred_list_result 297 | return new Response(JSON.stringify(list_result), option) 298 | } else { 299 | let file = await gd.file(path) 300 | let range = request.headers.get('Range') 301 | return new Response(JSON.stringify(file)) 302 | } 303 | } 304 | 305 | // 處理 search 306 | async function handleSearch(request, gd) { 307 | const option = { 308 | status: 200, 309 | headers: { 'Access-Control-Allow-Origin': '*' }, 310 | } 311 | let form = await request.formData() 312 | let search_result = await gd.search( 313 | form.get('q') || '', 314 | form.get('page_token'), 315 | Number(form.get('page_index')) 316 | ) 317 | return new Response(JSON.stringify(search_result), option) 318 | } 319 | 320 | /** 321 | * 處理 id2path 322 | * @param request 需要 id 參數 323 | * @param gd 324 | * @returns {Promise} 【注意】如果從前台接收的id代表的項目不在目標gd盤下,那麼response會返回給前台一個空字串"" 325 | */ 326 | async function handleId2Path(request, gd) { 327 | const option = { 328 | status: 200, 329 | headers: { 'Access-Control-Allow-Origin': '*' }, 330 | } 331 | let form = await request.formData() 332 | let path = await gd.findPathById(form.get('id')) 333 | return new Response(path || '', option) 334 | } 335 | 336 | class googleDrive { 337 | constructor(authConfig, order) { 338 | // 每個盤對應一個order,對應一個gd實例 339 | this.order = order 340 | this.root = authConfig.roots[order] 341 | this.root.protect_file_link = this.root.protect_file_link || false 342 | this.url_path_prefix = `/${order}:` 343 | this.authConfig = authConfig 344 | // TODO: 這些快取的失效刷新策略,後期可以制定一下 345 | // path id 346 | this.paths = [] 347 | // path file 348 | this.files = [] 349 | // path pass 350 | this.passwords = [] 351 | // id <-> path 352 | this.id_path_cache = {} 353 | this.id_path_cache[this.root['id']] = '/' 354 | this.paths['/'] = this.root['id'] 355 | /*if (this.root['pass'] != "") { 356 | this.passwords['/'] = this.root['pass']; 357 | }*/ 358 | // this.init(); 359 | } 360 | 361 | /** 362 | * 初次授權;然後獲取 user_drive_real_root_id 363 | * @returns {Promise} 364 | */ 365 | async init() { 366 | await this.accessToken() 367 | /*await (async () => { 368 | // 只獲取1次 369 | if (authConfig.user_drive_real_root_id) return; 370 | const root_obj = await (gds[0] || this).findItemById('root'); 371 | if (root_obj && root_obj.id) { 372 | authConfig.user_drive_real_root_id = root_obj.id 373 | } 374 | })();*/ 375 | // 等待 user_drive_real_root_id ,只獲取1次 376 | if (authConfig.user_drive_real_root_id) return 377 | const root_obj = await (gds[0] || this).findItemById('root') 378 | if (root_obj && root_obj.id) { 379 | authConfig.user_drive_real_root_id = root_obj.id 380 | } 381 | } 382 | 383 | /** 384 | * 獲取根目錄類型,設置到 root_type 385 | * @returns {Promise} 386 | */ 387 | async initRootType() { 388 | const root_id = this.root['id'] 389 | const types = CONSTS.gd_root_type 390 | if (root_id === 'root' || root_id === authConfig.user_drive_real_root_id) { 391 | this.root_type = types.user_drive 392 | } else { 393 | const obj = await this.getShareDriveObjById(root_id) 394 | this.root_type = obj ? types.share_drive : types.sub_folder 395 | } 396 | } 397 | 398 | /** 399 | * Returns a response that requires authorization, or null 400 | * @param request 401 | * @returns {Response|null} 402 | */ 403 | basicAuthResponse(request) { 404 | const user = this.root.user || '', 405 | pass = this.root.pass || '', 406 | _401 = new Response('Unauthorized', { 407 | headers: { 408 | 'WWW-Authenticate': `Basic realm="goindex:drive:${this.order}"`, 409 | }, 410 | status: 401, 411 | }) 412 | if (user || pass) { 413 | const auth = request.headers.get('Authorization') 414 | if (auth) { 415 | try { 416 | const [received_user, received_pass] = atob( 417 | auth.split(' ').pop() 418 | ).split(':') 419 | return received_user === user && received_pass === pass ? null : _401 420 | } catch (e) {} 421 | } 422 | } else return null 423 | return _401 424 | } 425 | 426 | async down(id, range = '', inline = false) { 427 | let url = `https://www.googleapis.com/drive/v3/files/${id}?alt=media` 428 | let requestOption = await this.requestOption() 429 | requestOption.headers['Range'] = range 430 | let res = await fetch(url, requestOption) 431 | const { headers } = (res = new Response(res.body, res)) 432 | this.authConfig.enable_cors_file_down && 433 | headers.append('Access-Control-Allow-Origin', '*') 434 | inline === true && headers.set('Content-Disposition', 'inline') 435 | return res 436 | } 437 | 438 | async file(path) { 439 | if (typeof this.files[path] == 'undefined') { 440 | this.files[path] = await this._file(path) 441 | } 442 | return this.files[path] 443 | } 444 | 445 | async _file(path) { 446 | let arr = path.split('/') 447 | let name = arr.pop() 448 | name = decodeURIComponent(name).replace(/\'/g, "\\'") 449 | let dir = arr.join('/') + '/' 450 | // console.log(name, dir); 451 | let parent = await this.findPathId(dir) 452 | // console.log(parent); 453 | let url = 'https://www.googleapis.com/drive/v3/files' 454 | let params = { includeItemsFromAllDrives: true, supportsAllDrives: true } 455 | params.q = `'${parent}' in parents and name = '${name}' and trashed = false` 456 | params.fields = 457 | 'files(id, name, mimeType, size ,createdTime, modifiedTime, iconLink, thumbnailLink)' 458 | url += '?' + this.enQuery(params) 459 | let requestOption = await this.requestOption() 460 | let response = await fetch(url, requestOption) 461 | let obj = await response.json() 462 | // console.log(obj); 463 | return obj.files[0] 464 | } 465 | 466 | // 通過reqeust cache 來快取 467 | async list(path, page_token = null, page_index = 0) { 468 | if (this.path_children_cache == undefined) { 469 | // { :[ {nextPageToken:'',data:{}}, {nextPageToken:'',data:{}} ...], ...} 470 | this.path_children_cache = {} 471 | } 472 | 473 | if ( 474 | this.path_children_cache[path] && 475 | this.path_children_cache[path][page_index] && 476 | this.path_children_cache[path][page_index].data 477 | ) { 478 | let child_obj = this.path_children_cache[path][page_index] 479 | return { 480 | nextPageToken: child_obj.nextPageToken || null, 481 | curPageIndex: page_index, 482 | data: child_obj.data, 483 | } 484 | } 485 | 486 | let id = await this.findPathId(path) 487 | let result = await this._ls(id, page_token, page_index) 488 | let data = result.data 489 | // 對有多頁的,進行快取 490 | if (result.nextPageToken && data.files) { 491 | if (!Array.isArray(this.path_children_cache[path])) { 492 | this.path_children_cache[path] = [] 493 | } 494 | this.path_children_cache[path][Number(result.curPageIndex)] = { 495 | nextPageToken: result.nextPageToken, 496 | data: data, 497 | } 498 | } 499 | 500 | return result 501 | } 502 | 503 | async _ls(parent, page_token = null, page_index = 0) { 504 | // console.log("_ls", parent); 505 | 506 | if (parent == undefined) { 507 | return null 508 | } 509 | let obj 510 | let params = { includeItemsFromAllDrives: true, supportsAllDrives: true } 511 | params.q = `'${parent}' in parents and trashed = false AND name !='.password'` 512 | params.orderBy = 'name_natural,folder,modifiedTime desc' 513 | params.fields = 514 | 'nextPageToken, files(id, name, mimeType, size , modifiedTime)' 515 | params.pageSize = this.authConfig.files_list_page_size 516 | 517 | if (page_token) { 518 | params.pageToken = page_token 519 | } 520 | let url = 'https://www.googleapis.com/drive/v3/files' 521 | url += '?' + this.enQuery(params) 522 | let requestOption = await this.requestOption() 523 | let response = await fetch(url, requestOption) 524 | obj = await response.json() 525 | 526 | return { 527 | nextPageToken: obj.nextPageToken || null, 528 | curPageIndex: page_index, 529 | data: obj, 530 | } 531 | 532 | /*do { 533 | if (pageToken) { 534 | params.pageToken = pageToken; 535 | } 536 | let url = 'https://www.googleapis.com/drive/v3/files'; 537 | url += '?' + this.enQuery(params); 538 | let requestOption = await this.requestOption(); 539 | let response = await fetch(url, requestOption); 540 | obj = await response.json(); 541 | files.push(...obj.files); 542 | pageToken = obj.nextPageToken; 543 | } while (pageToken);*/ 544 | } 545 | 546 | async password(path) { 547 | if (this.passwords[path] !== undefined) { 548 | return this.passwords[path] 549 | } 550 | 551 | // console.log("load", path, ".password", this.passwords[path]); 552 | 553 | let file = await this.file(path + '.password') 554 | if (file == undefined) { 555 | this.passwords[path] = null 556 | } else { 557 | let url = `https://www.googleapis.com/drive/v3/files/${file.id}?alt=media` 558 | let requestOption = await this.requestOption() 559 | let response = await this.fetch200(url, requestOption) 560 | this.passwords[path] = await response.text() 561 | } 562 | 563 | return this.passwords[path] 564 | } 565 | 566 | /** 567 | * 通過 id 獲取 share drive 訊息 568 | * @param any_id 569 | * @returns {Promise} 任何非正常情況都返回 null 570 | */ 571 | async getShareDriveObjById(any_id) { 572 | if (!any_id) return null 573 | if ('string' !== typeof any_id) return null 574 | 575 | let url = `https://www.googleapis.com/drive/v3/drives/${any_id}` 576 | let requestOption = await this.requestOption() 577 | let res = await fetch(url, requestOption) 578 | let obj = await res.json() 579 | if (obj && obj.id) return obj 580 | 581 | return null 582 | } 583 | 584 | /** 585 | * 搜索 586 | * @returns {Promise<{data: null, nextPageToken: null, curPageIndex: number}>} 587 | */ 588 | async search(origin_keyword, page_token = null, page_index = 0) { 589 | const types = CONSTS.gd_root_type 590 | const is_user_drive = this.root_type === types.user_drive 591 | const is_share_drive = this.root_type === types.share_drive 592 | 593 | const empty_result = { 594 | nextPageToken: null, 595 | curPageIndex: page_index, 596 | data: null, 597 | } 598 | 599 | if (!is_user_drive && !is_share_drive) { 600 | return empty_result 601 | } 602 | let keyword = FUNCS.formatSearchKeyword(origin_keyword) 603 | if (!keyword) { 604 | // 關鍵字為空,返回 605 | return empty_result 606 | } 607 | let words = keyword.split(/\s+/) 608 | let name_search_str = `name contains '${words.join( 609 | "' AND name contains '" 610 | )}'` 611 | 612 | // corpora 為 user 是個人盤 ,為 drive 是團隊盤。配合 driveId 613 | let params = {} 614 | if (is_user_drive) { 615 | params.corpora = 'user' 616 | } 617 | if (is_share_drive) { 618 | params.corpora = 'drive' 619 | params.driveId = this.root.id 620 | // This parameter will only be effective until June 1, 2020. Afterwards shared drive items will be included in the results. 621 | params.includeItemsFromAllDrives = true 622 | params.supportsAllDrives = true 623 | } 624 | if (page_token) { 625 | params.pageToken = page_token 626 | } 627 | params.q = `trashed = false AND name !='.password' AND (${name_search_str})` 628 | params.fields = 629 | 'nextPageToken, files(id, name, mimeType, size , modifiedTime)' 630 | params.pageSize = this.authConfig.search_result_list_page_size 631 | params.orderBy = 'folder,name_natural,modifiedTime desc' 632 | 633 | let url = 'https://www.googleapis.com/drive/v3/files' 634 | url += '?' + this.enQuery(params) 635 | // console.log(params) 636 | let requestOption = await this.requestOption() 637 | let response = await fetch(url, requestOption) 638 | let res_obj = await response.json() 639 | 640 | return { 641 | nextPageToken: res_obj.nextPageToken || null, 642 | curPageIndex: page_index, 643 | data: res_obj, 644 | } 645 | } 646 | 647 | /** 648 | * 一層一層的向上獲取這個文件或文件夾的上級文件夾的 file 對象。注意:會很慢!!! 649 | * 最多向上尋找到當前 gd 對象的根目錄 (root id) 650 | * 只考慮一條單獨的向上鏈。 651 | * 【注意】如果此id代表的項目不在目標gd盤下,那麼此函數會返回null 652 | * 653 | * @param child_id 654 | * @param contain_myself 655 | * @returns {Promise<[]>} 656 | */ 657 | async findParentFilesRecursion(child_id, contain_myself = true) { 658 | const gd = this 659 | const gd_root_id = gd.root.id 660 | const user_drive_real_root_id = authConfig.user_drive_real_root_id 661 | const is_user_drive = gd.root_type === CONSTS.gd_root_type.user_drive 662 | 663 | // 自下向上查詢的終點目標id 664 | const target_top_id = is_user_drive ? user_drive_real_root_id : gd_root_id 665 | const fields = CONSTS.default_file_fields 666 | 667 | // [{},{},...] 668 | const parent_files = [] 669 | let meet_top = false 670 | 671 | async function addItsFirstParent(file_obj) { 672 | if (!file_obj) return 673 | if (!file_obj.parents) return 674 | if (file_obj.parents.length < 1) return 675 | 676 | // ['','',...] 677 | let p_ids = file_obj.parents 678 | if (p_ids && p_ids.length > 0) { 679 | // its first parent 680 | const first_p_id = p_ids[0] 681 | if (first_p_id === target_top_id) { 682 | meet_top = true 683 | return 684 | } 685 | const p_file_obj = await gd.findItemById(first_p_id) 686 | if (p_file_obj && p_file_obj.id) { 687 | parent_files.push(p_file_obj) 688 | await addItsFirstParent(p_file_obj) 689 | } 690 | } 691 | } 692 | 693 | const child_obj = await gd.findItemById(child_id) 694 | if (contain_myself) { 695 | parent_files.push(child_obj) 696 | } 697 | await addItsFirstParent(child_obj) 698 | 699 | return meet_top ? parent_files : null 700 | } 701 | 702 | /** 703 | * 獲取相對於本盤根目錄的path 704 | * @param child_id 705 | * @returns {Promise} 【注意】如果此id代表的項目不在目標gd盤下,那麼此方法會返回空字串"" 706 | */ 707 | async findPathById(child_id) { 708 | if (this.id_path_cache[child_id]) { 709 | return this.id_path_cache[child_id] 710 | } 711 | 712 | const p_files = await this.findParentFilesRecursion(child_id) 713 | if (!p_files || p_files.length < 1) return '' 714 | 715 | let cache = [] 716 | // 把查出來的每一級的path和id都快取一下 717 | p_files.forEach((value, idx) => { 718 | const is_folder = 719 | idx === 0 ? p_files[idx].mimeType === CONSTS.folder_mime_type : true 720 | let path = 721 | '/' + 722 | p_files 723 | .slice(idx) 724 | .map((it) => it.name) 725 | .reverse() 726 | .join('/') 727 | if (is_folder) path += '/' 728 | cache.push({ id: p_files[idx].id, path: path }) 729 | }) 730 | 731 | cache.forEach((obj) => { 732 | this.id_path_cache[obj.id] = obj.path 733 | this.paths[obj.path] = obj.id 734 | }) 735 | 736 | /*const is_folder = p_files[0].mimeType === CONSTS.folder_mime_type; 737 | let path = '/' + p_files.map(it => it.name).reverse().join('/'); 738 | if (is_folder) path += '/';*/ 739 | 740 | return cache[0].path 741 | } 742 | 743 | // 根據id獲取file item 744 | async findItemById(id) { 745 | const is_user_drive = this.root_type === CONSTS.gd_root_type.user_drive 746 | let url = `https://www.googleapis.com/drive/v3/files/${id}?fields=${ 747 | CONSTS.default_file_fields 748 | }${is_user_drive ? '' : '&supportsAllDrives=true'}` 749 | let requestOption = await this.requestOption() 750 | let res = await fetch(url, requestOption) 751 | return await res.json() 752 | } 753 | 754 | async findPathId(path) { 755 | let c_path = '/' 756 | let c_id = this.paths[c_path] 757 | 758 | let arr = path.trim('/').split('/') 759 | for (let name of arr) { 760 | c_path += name + '/' 761 | 762 | if (typeof this.paths[c_path] == 'undefined') { 763 | let id = await this._findDirId(c_id, name) 764 | this.paths[c_path] = id 765 | } 766 | 767 | c_id = this.paths[c_path] 768 | if (c_id == undefined || c_id == null) { 769 | break 770 | } 771 | } 772 | // console.log(this.paths); 773 | return this.paths[path] 774 | } 775 | 776 | async _findDirId(parent, name) { 777 | name = decodeURIComponent(name).replace(/\'/g, "\\'") 778 | 779 | // console.log("_findDirId", parent, name); 780 | 781 | if (parent == undefined) { 782 | return null 783 | } 784 | 785 | let url = 'https://www.googleapis.com/drive/v3/files' 786 | let params = { includeItemsFromAllDrives: true, supportsAllDrives: true } 787 | params.q = `'${parent}' in parents and mimeType = 'application/vnd.google-apps.folder' and name = '${name}' and trashed = false` 788 | params.fields = 'nextPageToken, files(id, name, mimeType)' 789 | url += '?' + this.enQuery(params) 790 | let requestOption = await this.requestOption() 791 | let response = await fetch(url, requestOption) 792 | let obj = await response.json() 793 | if (obj.files[0] == undefined) { 794 | return null 795 | } 796 | return obj.files[0].id 797 | } 798 | 799 | async accessToken() { 800 | console.log('accessToken') 801 | if ( 802 | this.authConfig.expires == undefined || 803 | this.authConfig.expires < Date.now() 804 | ) { 805 | const obj = await this.fetchAccessToken() 806 | if (obj.access_token != undefined) { 807 | this.authConfig.accessToken = obj.access_token 808 | this.authConfig.expires = Date.now() + 3500 * 1000 809 | } 810 | } 811 | return this.authConfig.accessToken 812 | } 813 | 814 | async fetchAccessToken() { 815 | console.log('fetchAccessToken') 816 | const url = 'https://www.googleapis.com/oauth2/v4/token' 817 | const headers = { 818 | 'Content-Type': 'application/x-www-form-urlencoded', 819 | } 820 | const post_data = { 821 | client_id: this.authConfig.client_id, 822 | client_secret: this.authConfig.client_secret, 823 | refresh_token: this.authConfig.refresh_token, 824 | grant_type: 'refresh_token', 825 | } 826 | 827 | let requestOption = { 828 | method: 'POST', 829 | headers: headers, 830 | body: this.enQuery(post_data), 831 | } 832 | 833 | const response = await fetch(url, requestOption) 834 | return await response.json() 835 | } 836 | 837 | async fetch200(url, requestOption) { 838 | let response 839 | for (let i = 0; i < 3; i++) { 840 | response = await fetch(url, requestOption) 841 | console.log(response.status) 842 | if (response.status != 403) { 843 | break 844 | } 845 | await this.sleep(800 * (i + 1)) 846 | } 847 | return response 848 | } 849 | 850 | async requestOption(headers = {}, method = 'GET') { 851 | const accessToken = await this.accessToken() 852 | headers['authorization'] = 'Bearer ' + accessToken 853 | return { method: method, headers: headers } 854 | } 855 | 856 | enQuery(data) { 857 | const ret = [] 858 | for (let d in data) { 859 | ret.push(encodeURIComponent(d) + '=' + encodeURIComponent(data[d])) 860 | } 861 | return ret.join('&') 862 | } 863 | 864 | sleep(ms) { 865 | return new Promise(function (resolve, reject) { 866 | let i = 0 867 | setTimeout(function () { 868 | console.log('sleep' + ms) 869 | i++ 870 | if (i >= 2) reject(new Error('i>=2')) 871 | else resolve(i) 872 | }, ms) 873 | }) 874 | } 875 | } 876 | 877 | String.prototype.trim = function (char) { 878 | if (char) { 879 | return this.replace( 880 | new RegExp('^\\' + char + '+|\\' + char + '+$', 'g'), 881 | '' 882 | ) 883 | } 884 | return this.replace(/^\s+|\s+$/g, '') 885 | } 886 | -------------------------------------------------------------------------------- /index.min.js: -------------------------------------------------------------------------------- 1 | const authConfig = { 2 | siteName: 'NekoChan Open Data', 3 | version: '', 4 | client_id: '', 5 | client_secret: '', 6 | refresh_token: '', 7 | /** 8 | * 設置要顯示的多個雲端硬碟;按格式添加多個 9 | * [id]: 可以是 團隊盤id、子文件夾id、或者"root"(代表個人盤根目錄) 10 | * [name]: 顯示的名稱 11 | * [user]: Basic Auth 的使用者名稱 12 | * [pass]: Basic Auth 的密碼 13 | * [protect_file_link]: Basic Auth 是否用於保護文件連結,預設值(不設置時)為 false,即不保護文件連結(方便 直鏈下載/外部播放 等) 14 | * 每個盤的 Basic Auth 都可以單獨設置。Basic Auth 默認保護該盤下所有文件夾/子文件夾路徑 15 | * 【注意】默認不保護文件連結,這樣可以方便 直鏈下載/外部播放 16 | * 如果要保護文件連結,需要將 protect_file_link 設置為 true,此時如果要進行外部播放等操作,需要將 host 替換為 user:pass@host 的 形式 17 | * 不需要 Basic Auth 的盤,保持 user 和 pass 同時為空即可。(直接不設置也可以) 18 | * 【注意】對於id設置為為子文件夾id的盤將不支持搜尋功能(不影響其他盤)。 19 | */ 20 | roots: [ 21 | { 22 | id: 'root', 23 | name: '主目錄', 24 | }, 25 | ], 26 | /** 27 | * 文件列表頁面每頁顯示的數量。【推薦設置值為 100 到 1000 之間】; 28 | * 如果設置大於1000,會導致請求 drive api 時出錯; 29 | * 如果設置的值過小,會導致文件列表頁面滾動條增量載入(分頁載入)失效; 30 | * 此值的另一個作用是,如果目錄內文件數大於此設置值(即需要多頁展示的),將會對首次列目錄結果進行快取。 31 | */ 32 | files_list_page_size: 50, 33 | /** 34 | * 搜索結果頁面每頁顯示的數量。【推薦設置值為 50 到 1000 之間】; 35 | * 如果設置大於1000,會導致請求 drive api 時出錯; 36 | * 如果設置的值過小,會導致搜索結果頁面滾動條增量載入(分頁載入)失效; 37 | * 此值的大小影響搜索操作的響應速度。 38 | */ 39 | search_result_list_page_size: 50, 40 | // 確認有 cors 用途的可以開啟 41 | enable_cors_file_down: false, 42 | /** 43 | * 上面的 basic auth 已經包含了盤內全局保護的功能。所以默認不再去認證 .password 文件內的密碼 44 | * 如果在全局認證的基礎上,仍需要給某些目錄單獨進行 .password 文件內的密碼驗證的話,將此选项設置為 true 45 | * 【注意】如果開啟了 .password 文件密碼驗證,每次列目錄都會額外增加查詢目錄內 .password 文件是否存在的開銷。 46 | */ 47 | enable_password_file_verify: false, 48 | } 49 | 50 | /** 51 | * web ui 設置 52 | */ 53 | const uiConfig = { 54 | main_color: 'blue', 55 | accent_color: 'blue', 56 | } 57 | 58 | /** 59 | * global functions 60 | */ 61 | const FUNCS = { 62 | /** 63 | * 轉換成針對Google搜索詞法相對安全的搜索關鍵字 64 | */ 65 | formatSearchKeyword: function (keyword) { 66 | let nothing = '' 67 | let space = ' ' 68 | if (!keyword) return nothing 69 | return keyword 70 | .replace(/(!=)|['"=<>/\\:]/g, nothing) 71 | .replace(/[,,|(){}]/g, space) 72 | .trim() 73 | }, 74 | } 75 | 76 | /** 77 | * global consts 78 | * @type {{folder_mime_type: string, default_file_fields: string, gd_root_type: {share_drive: number, user_drive: number, sub_folder: number}}} 79 | */ 80 | const CONSTS = new (class { 81 | default_file_fields = 82 | 'parents,id,name,mimeType,modifiedTime,createdTime,fileExtension,size' 83 | gd_root_type = { 84 | user_drive: 0, 85 | share_drive: 1, 86 | sub_folder: 2, 87 | } 88 | folder_mime_type = 'application/vnd.google-apps.folder' 89 | })() 90 | 91 | // gd instances 92 | var gds = [] 93 | 94 | function html(current_drive_order = 0, model = {}) { 95 | return ` 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | ${authConfig.siteName} 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 125 | 126 | 127 | 128 | 129 | 130 | ` 131 | } 132 | 133 | addEventListener('fetch', (e) => { 134 | e.respondWith(handleRequest(e.request)) 135 | }) 136 | 137 | /** 138 | * Fetch and log a request 139 | * @param {Request} request 140 | */ 141 | async function handleRequest(request) { 142 | if (gds.length === 0) { 143 | for (let i = 0; i < authConfig.roots.length; i++) { 144 | const gd = new googleDrive(authConfig, i) 145 | await gd.init() 146 | gds.push(gd) 147 | } 148 | // 這個操作並行,提高效率 149 | let tasks = [] 150 | gds.forEach((gd) => { 151 | tasks.push(gd.initRootType()) 152 | }) 153 | for (let task of tasks) { 154 | await task 155 | } 156 | } 157 | 158 | // 從 path 中提取 drive order 159 | // 並根據 drive order 獲取對應的 gd instance 160 | let gd, 161 | url = new URL(request.url), 162 | path = url.pathname 163 | 164 | /** 165 | * 重定向至起始頁 166 | * @returns {Response} 167 | */ 168 | function redirectToIndexPage() { 169 | return new Response('', { 170 | status: 301, 171 | headers: { Location: `${url.origin}/0:/` }, 172 | }) 173 | } 174 | 175 | if (path == '/') return redirectToIndexPage() 176 | if (path.toLowerCase() == '/favicon.ico') { 177 | // 後面可以找一個 favicon 178 | return new Response('', { status: 404 }) 179 | } 180 | 181 | // 特殊命令格式 182 | const command_reg = /^\/(?\d+):(?[a-zA-Z0-9]+)$/g 183 | const match = command_reg.exec(path) 184 | if (match) { 185 | const num = match.groups.num 186 | const order = Number(num) 187 | if (order >= 0 && order < gds.length) { 188 | gd = gds[order] 189 | } else { 190 | return redirectToIndexPage() 191 | } 192 | // basic auth 193 | for (const r = gd.basicAuthResponse(request); r; ) return r 194 | const command = match.groups.command 195 | // 搜索 196 | if (command === 'search') { 197 | if (request.method === 'POST') { 198 | // 搜索結果 199 | return handleSearch(request, gd) 200 | } else { 201 | const params = url.searchParams 202 | // 搜索頁面 203 | return new Response( 204 | html(gd.order, { 205 | q: params.get('q') || '', 206 | is_search_page: true, 207 | root_type: gd.root_type, 208 | }), 209 | { 210 | status: 200, 211 | headers: { 'Content-Type': 'text/html; charset=utf-8' }, 212 | } 213 | ) 214 | } 215 | } else if (command === 'id2path' && request.method === 'POST') { 216 | return handleId2Path(request, gd) 217 | } 218 | } 219 | 220 | // 期望的 path 格式 221 | const common_reg = /^\/\d+:\/.*$/g 222 | try { 223 | if (!path.match(common_reg)) { 224 | return redirectToIndexPage() 225 | } 226 | let split = path.split('/'), 227 | order = Number(split[1].slice(0, -1)) 228 | if (order >= 0 && order < gds.length) { 229 | gd = gds[order] 230 | } else { 231 | return redirectToIndexPage() 232 | } 233 | } catch (e) { 234 | return redirectToIndexPage() 235 | } 236 | 237 | // basic auth 238 | // for (const r = gd.basicAuthResponse(request); r;) return r; 239 | const basic_auth_res = gd.basicAuthResponse(request) 240 | 241 | path = path.replace(gd.url_path_prefix, '') || '/' 242 | if (request.method == 'POST') { 243 | return basic_auth_res || apiRequest(request, gd) 244 | } 245 | 246 | let action = url.searchParams.get('a') 247 | 248 | if (path.substr(-1) == '/' || action != null) { 249 | return ( 250 | basic_auth_res || 251 | new Response(html(gd.order, { root_type: gd.root_type }), { 252 | status: 200, 253 | headers: { 'Content-Type': 'text/html; charset=utf-8' }, 254 | }) 255 | ) 256 | } else { 257 | if (path.split('/').pop().toLowerCase() == '.password') { 258 | return basic_auth_res || new Response('', { status: 404 }) 259 | } 260 | let file = await gd.file(path) 261 | let range = request.headers.get('Range') 262 | const inline_down = 'true' === url.searchParams.get('inline') 263 | if (gd.root.protect_file_link && basic_auth_res) return basic_auth_res 264 | return gd.down(file.id, range, inline_down) 265 | } 266 | } 267 | 268 | async function apiRequest(e,t){let i=new URL(e.url).pathname,s={status:200,headers:{"Access-Control-Allow-Origin":"*"}};if("/"==(i=i.replace(t.url_path_prefix,"")||"/").substr(-1)){let a=await e.formData(),n=t.list(i,a.get("page_token"),Number(a.get("page_index")));if(authConfig.enable_password_file_verify){let e=await t.password(i);if(e&&e.replace("\n","")!==a.get("password"))return new Response('{"error": {"code": 401,"message": "password error."}}',s)}let r=await n;return new Response(JSON.stringify(r),s)}{let s=await t.file(i);return e.headers.get("Range"),new Response(JSON.stringify(s))}}async function handleSearch(e,t){let i=await e.formData(),s=await t.search(i.get("q")||"",i.get("page_token"),Number(i.get("page_index")));return new Response(JSON.stringify(s),{status:200,headers:{"Access-Control-Allow-Origin":"*"}})}async function handleId2Path(e,t){let i=await e.formData(),s=await t.findPathById(i.get("id"));return new Response(s||"",{status:200,headers:{"Access-Control-Allow-Origin":"*"}})}class googleDrive{constructor(e,t){this.order=t,this.root=e.roots[t],this.root.protect_file_link=this.root.protect_file_link||!1,this.url_path_prefix=`/${t}:`,this.authConfig=e,this.paths=[],this.files=[],this.passwords=[],this.id_path_cache={},this.id_path_cache[this.root.id]="/",this.paths["/"]=this.root.id}async init(){if(await this.accessToken(),authConfig.user_drive_real_root_id)return;const e=await(gds[0]||this).findItemById("root");e&&e.id&&(authConfig.user_drive_real_root_id=e.id)}async initRootType(){const e=this.root.id,t=CONSTS.gd_root_type;if("root"===e||e===authConfig.user_drive_real_root_id)this.root_type=t.user_drive;else{const i=await this.getShareDriveObjById(e);this.root_type=i?t.share_drive:t.sub_folder}}basicAuthResponse(e){const t=this.root.user||"",i=this.root.pass||"",s=new Response("Unauthorized",{headers:{"WWW-Authenticate":`Basic realm="goindex:drive:${this.order}"`},status:401});if(!t&&!i)return null;{const a=e.headers.get("Authorization");if(a)try{const[n,r]=atob(a.split(" ").pop()).split(":");return n===t&&r===i?null:s}catch(e){}}return s}async down(e,t="",i=!1){let s=`https://www.googleapis.com/drive/v3/files/${e}?alt=media`,a=await this.requestOption();a.headers.Range=t;let n=await fetch(s,a);const{headers:r}=n=new Response(n.body,n);return this.authConfig.enable_cors_file_down&&r.append("Access-Control-Allow-Origin","*"),!0===i&&r.set("Content-Disposition","inline"),n}async file(e){return void 0===this.files[e]&&(this.files[e]=await this._file(e)),this.files[e]}async _file(e){let t=e.split("/"),i=t.pop();i=decodeURIComponent(i).replace(/\'/g,"\\'");let s=t.join("/")+"/",a=await this.findPathId(s),n="https://www.googleapis.com/drive/v3/files",r={includeItemsFromAllDrives:!0,supportsAllDrives:!0};r.q=`'${a}' in parents and name = '${i}' and trashed = false`,r.fields="files(id, name, mimeType, size ,createdTime, modifiedTime, iconLink, thumbnailLink)",n+="?"+this.enQuery(r);let o=await this.requestOption(),l=await fetch(n,o);return(await l.json()).files[0]}async list(e,t=null,i=0){if(null==this.path_children_cache&&(this.path_children_cache={}),this.path_children_cache[e]&&this.path_children_cache[e][i]&&this.path_children_cache[e][i].data){let t=this.path_children_cache[e][i];return{nextPageToken:t.nextPageToken||null,curPageIndex:i,data:t.data}}let s=await this.findPathId(e),a=await this._ls(s,t,i),n=a.data;return a.nextPageToken&&n.files&&(Array.isArray(this.path_children_cache[e])||(this.path_children_cache[e]=[]),this.path_children_cache[e][Number(a.curPageIndex)]={nextPageToken:a.nextPageToken,data:n}),a}async _ls(e,t=null,i=0){if(null==e)return null;let s,a={includeItemsFromAllDrives:!0,supportsAllDrives:!0};a.q=`'${e}' in parents and trashed = false AND name !='.password'`,a.orderBy="name_natural,folder,modifiedTime desc",a.fields="nextPageToken, files(id, name, mimeType, size , modifiedTime)",a.pageSize=this.authConfig.files_list_page_size,t&&(a.pageToken=t);let n="https://www.googleapis.com/drive/v3/files";n+="?"+this.enQuery(a);let r=await this.requestOption(),o=await fetch(n,r);return{nextPageToken:(s=await o.json()).nextPageToken||null,curPageIndex:i,data:s}}async password(e){if(void 0!==this.passwords[e])return this.passwords[e];let t=await this.file(e+".password");if(null==t)this.passwords[e]=null;else{let i=`https://www.googleapis.com/drive/v3/files/${t.id}?alt=media`,s=await this.requestOption(),a=await this.fetch200(i,s);this.passwords[e]=await a.text()}return this.passwords[e]}async getShareDriveObjById(e){if(!e)return null;if("string"!=typeof e)return null;let t=`https://www.googleapis.com/drive/v3/drives/${e}`,i=await this.requestOption(),s=await fetch(t,i),a=await s.json();return a&&a.id?a:null}async search(e,t=null,i=0){const s=CONSTS.gd_root_type,a=this.root_type===s.user_drive,n=this.root_type===s.share_drive,r={nextPageToken:null,curPageIndex:i,data:null};if(!a&&!n)return r;let o=FUNCS.formatSearchKeyword(e);if(!o)return r;let l=`name contains '${o.split(/\s+/).join("' AND name contains '")}'`,h={};a&&(h.corpora="user"),n&&(h.corpora="drive",h.driveId=this.root.id,h.includeItemsFromAllDrives=!0,h.supportsAllDrives=!0),t&&(h.pageToken=t),h.q=`trashed = false AND name !='.password' AND (${l})`,h.fields="nextPageToken, files(id, name, mimeType, size , modifiedTime)",h.pageSize=this.authConfig.search_result_list_page_size,h.orderBy="folder,name_natural,modifiedTime desc";let d="https://www.googleapis.com/drive/v3/files";d+="?"+this.enQuery(h);let c=await this.requestOption(),p=await fetch(d,c),u=await p.json();return{nextPageToken:u.nextPageToken||null,curPageIndex:i,data:u}}async findParentFilesRecursion(e,t=!0){const i=this,s=i.root.id,a=authConfig.user_drive_real_root_id,n=i.root_type===CONSTS.gd_root_type.user_drive?a:s,r=(CONSTS.default_file_fields,[]);let o=!1;const l=await i.findItemById(e);return t&&r.push(l),await async function e(t){if(!t)return;if(!t.parents)return;if(t.parents.length<1)return;let s=t.parents;if(s&&s.length>0){const t=s[0];if(t===n)return void(o=!0);const a=await i.findItemById(t);a&&a.id&&(r.push(a),await e(a))}}(l),o?r:null}async findPathById(e){if(this.id_path_cache[e])return this.id_path_cache[e];const t=await this.findParentFilesRecursion(e);if(!t||t.length<1)return"";let i=[];return t.forEach((e,s)=>{const a=0!==s||t[s].mimeType===CONSTS.folder_mime_type;let n="/"+t.slice(s).map(e=>e.name).reverse().join("/");a&&(n+="/"),i.push({id:t[s].id,path:n})}),i.forEach(e=>{this.id_path_cache[e.id]=e.path,this.paths[e.path]=e.id}),i[0].path}async findItemById(e){const t=this.root_type===CONSTS.gd_root_type.user_drive;let i=`https://www.googleapis.com/drive/v3/files/${e}?fields=${CONSTS.default_file_fields}${t?"":"&supportsAllDrives=true"}`,s=await this.requestOption(),a=await fetch(i,s);return await a.json()}async findPathId(e){let t="/",i=this.paths[t],s=e.trim("/").split("/");for(let e of s){if(t+=e+"/",void 0===this.paths[t]){let s=await this._findDirId(i,e);this.paths[t]=s}if(null==(i=this.paths[t])||null==i)break}return this.paths[e]}async _findDirId(e,t){if(t=decodeURIComponent(t).replace(/\'/g,"\\'"),null==e)return null;let i="https://www.googleapis.com/drive/v3/files",s={includeItemsFromAllDrives:!0,supportsAllDrives:!0};s.q=`'${e}' in parents and mimeType = 'application/vnd.google-apps.folder' and name = '${t}' and trashed = false`,s.fields="nextPageToken, files(id, name, mimeType)",i+="?"+this.enQuery(s);let a=await this.requestOption(),n=await fetch(i,a),r=await n.json();return null==r.files[0]?null:r.files[0].id}async accessToken(){if(console.log("accessToken"),null==this.authConfig.expires||this.authConfig.expires=2?i(new Error("i>=2")):t(s)},e)})}}String.prototype.trim=function(e){return e?this.replace(new RegExp("^\\"+e+"+|\\"+e+"+$","g"),""):this.replace(/^\s+|\s+$/g,"")}; -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | // NekoChan Open Data 2 | 3 | 'use strict' 4 | 5 | // 系統識別 6 | const Os = { 7 | isWindows: navigator.platform.toUpperCase().includes('WIN'), // .includes 8 | isMac: navigator.platform.toUpperCase().includes('MAC'), 9 | isMacLike: /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform), 10 | isIos: /(iPhone|iPod|iPad)/i.test(navigator.platform), 11 | isMobile: /Android|webOS|iPhone|iPad|iPod|iOS|BlackBerry|IEMobile|Opera Mini/i.test( 12 | navigator.userAgent 13 | ), 14 | } 15 | 16 | // 預加載圖片 17 | let imageUrls = [] 18 | function preloadImages(imageUrls) { 19 | var name 20 | for (let i = 0, length = imageUrls.length; i < length; i++) { 21 | name = `index_${i}` // 動態生成變量 22 | window[name] = 0 23 | window[`index_${i}`] = new Image () 24 | window[`index_${i}`].onload = () => {} 25 | window[`index_${i}`].crossOrigin = '' 26 | window[`index_${i}`].src = imageUrls[i] 27 | } 28 | } 29 | 30 | // 初始化頁面,並載入必要資源 31 | function init() { 32 | document.siteName = $('title').html() 33 | $('body').addClass(`mdui-theme-primary-${UI.main_color} mdui-theme-accent-${UI.accent_color}`) 34 | let html = ` 35 | 38 |
    39 |
    40 |
    41 |
    42 | 43 |
    44 |
    ` 45 | $('body').html(html) 46 | if (/(WIN|Mac)/i.test(navigator.userAgent)) { 47 | $(() => { 48 | // 資料夾預覽圖 49 | const folderIMGElement = $('#folderIMGElement') 50 | folderIMGElement.hide() 51 | $(document).mousemove((event) => { 52 | folderIMGElement.css({'left':`${event.pageX + 25}px`, 'top':`${event.pageY + 25}px`}) // 滑鼠移動時 資料夾預覽圖元素 跟著移動 53 | }) 54 | $(window).on('scroll', function() { 55 | folderIMGElement.hide() // 滾動時隱藏 資料夾預覽圖元素 56 | }) 57 | }) 58 | } 59 | 60 | // 導航條黏貼 61 | $(window).on('scroll', function() { 62 | if($(window).scrollTop() > 0) { 63 | $('#nav').css({'position': 'fixed'}) 64 | } else if($(window).scrollTop() === 0) { 65 | $('#nav').css({'position': 'static'}) 66 | } 67 | }) 68 | } 69 | 70 | function getDocumentHeight() { 71 | let D = document 72 | return Math.max( 73 | D.body.scrollHeight, 74 | D.documentElement.scrollHeight, 75 | D.body.offsetHeight, 76 | D.documentElement.offsetHeight, 77 | D.body.clientHeight, 78 | D.documentElement.clientHeight 79 | ) 80 | } 81 | 82 | function render(path) { 83 | if (path.indexOf('?') > 0) { 84 | path = path.substr(0, path.indexOf('?')) 85 | } 86 | title(path) 87 | nav(path) 88 | // .../0: 這種 89 | let reg = /\/\d+:$/g 90 | if (window.MODEL.is_search_page) { 91 | // 用來存儲一些滾動事件的狀態 92 | window.scroll_status = { 93 | // 滾動事件是否已經綁定 94 | event_bound: false, 95 | // "滾動到底部,正在載入更多數據" 事件的鎖 96 | loading_lock: false, 97 | } 98 | render_search_result_list() 99 | } else if (path.match(reg) || path.substr(-1) == '/') { 100 | // 用來存儲一些滾動事件的狀態 101 | window.scroll_status = { 102 | // 滾動事件是否已經綁定 103 | event_bound: false, 104 | // "滾動到底部,正在載入更多數據" 事件的鎖 105 | loading_lock: false, 106 | } 107 | list(path) 108 | } else { 109 | file(path) 110 | } 111 | } 112 | 113 | // 渲染 title 114 | function title(path) { 115 | path = decodeURI(path) 116 | let cur = window.current_drive_order || 0, 117 | drive_name = window.drive_names[cur], 118 | model = window.MODEL 119 | path = path.replace(`/${cur}:`, '') 120 | $('title').html(`${document.siteName} - ${path}`) 121 | if (model.is_search_page) 122 | $('title').html( 123 | `${document.siteName} - ${drive_name} - 搜尋 ${model.q} 的結果` 124 | ) 125 | else $('title').html(`${document.siteName} - ${drive_name} - ${path}`) 126 | $('title').html(`${document.siteName}`) 127 | } 128 | 129 | // 渲染導航欄 130 | function nav(path) { 131 | let model = window.MODEL, 132 | html = '', 133 | cur = window.current_drive_order || 0 134 | html += `${document.siteName}` 135 | 136 | let folderPath = `當前位置: 主目錄` 137 | if (!model.is_search_page) { 138 | // 資料夾路徑 139 | let arr = path.trim('/').split('/'), 140 | p = '/' 141 | if (arr.length > 1) { 142 | arr.shift() 143 | for (let i in arr) { 144 | let n = arr[i] 145 | n = decodeURI(n) 146 | p += `${n}/` 147 | // 只顯示資料夾 148 | if ( 149 | n == '' || 150 | /md|mp4|webm|avi|bmp|jpg|jpeg|png|gif|m4a|mp3|flac|wav|ogg|mpg|mpeg|mkv|rm|rmvb|mov|wmv|asf|ts|flv/.test(n) 151 | ) { 152 | break 153 | } 154 | folderPath += `chevron_right${n}` 155 | } 156 | } 157 | } 158 | $('#folderPath').html(folderPath) 159 | 160 | let search_text = model.is_search_page ? model.q || '' : '', 161 | search_bar = `
    162 | ` 170 | 171 | // // 個人盤 或 團隊盤 172 | // if (model.root_type < 2) { 173 | // // 顯示搜索框 174 | // html += search_bar 175 | // } 176 | 177 | html += search_bar 178 | 179 | $('#nav').html(html) 180 | mdui.mutation() 181 | mdui.updateTextFields() 182 | } 183 | 184 | /** 185 | * 發起列目錄的 POST 請求 186 | * @param path Path 187 | * @param params Form params 188 | * @param resultCallback Success Result Callback 189 | * @param authErrorCallback Pass Error Callback 190 | */ 191 | function requestListPath(path, params, resultCallback, authErrorCallback) { 192 | let p = { 193 | password: params['password'] || null, 194 | page_token: params['page_token'] || null, 195 | page_index: params['page_index'] || 0, 196 | } 197 | $.post(path, p, (data, status) => { 198 | let res = jQuery.parseJSON(data) 199 | if (res && res.error && res.error.code == '401') { 200 | // 密碼驗證失敗 201 | if (authErrorCallback) authErrorCallback(path) 202 | } else if (res && res.data) { 203 | if (resultCallback) resultCallback(res, path, p) 204 | } 205 | }) 206 | } 207 | 208 | /** 209 | * 搜索 POST 請求 210 | * @param params Form params 211 | * @param resultCallback Success callback 212 | */ 213 | function requestSearch(params, resultCallback) { 214 | let p = { 215 | q: params['q'] || null, 216 | page_token: params['page_token'] || null, 217 | page_index: params['page_index'] || 0, 218 | } 219 | $.post(`/${window.current_drive_order}:search`, p, (data, status) => { 220 | let res = jQuery.parseJSON(data) 221 | if (res && res.data) { 222 | if (resultCallback) resultCallback(res, p) 223 | } 224 | }) 225 | } 226 | 227 | // 渲染文件列表 228 | function list(path) { 229 | let href = null, // 資料夾預覽圖連結 230 | content = ` 231 | 232 |
    233 |
      234 |
    • 235 |
      檔案名稱expand_more
      236 |
      檔案大小expand_more
      237 |
    • 238 |
    239 |
    240 |
    241 |
      242 |
    243 |

    Discord:NekoChan#2851
    返回頂部
    244 |
    245 | ` 246 | $('#content').html(content) 247 | 248 | let password = localStorage.getItem(`password${path}`) 249 | $('#list').html( 250 | `
    ` 251 | ) 252 | $('#readme_md').hide().html('') 253 | $('#head_md').hide().html('') 254 | 255 | /** 256 | * 列目錄請求成功返回數據後的回調 257 | * @param res 返回的結果(object) 258 | * @param path 請求的路徑 259 | * @param prevReqParams 請求時所用的參數 260 | */ 261 | function successResultCallback(res, path, prevReqParams) { 262 | // 把 nextPageToken 和 currentPageIndex 暫存在 list元素 中 263 | $('#list') 264 | .data('nextPageToken', res['nextPageToken']) 265 | .data('curPageIndex', res['curPageIndex']) 266 | 267 | // 移除 loading spinner 268 | $('#spinner').remove() 269 | 270 | if (res['nextPageToken'] === null) { 271 | // 如果是最後一頁,取消綁定 scroll 事件,重置 scroll_status ,並 append 數據 272 | $(window).off('scroll') 273 | window.scroll_status.event_bound = false 274 | window.scroll_status.loading_lock = false 275 | append_files_to_list(path, res['data']['files']) 276 | preloadImages(imageUrls) // 開始預加載封面 277 | // 資料夾預覽圖 278 | $('.clickFolder').hover( 279 | function () { 280 | href = `${this.querySelector('a.folder').href}封面.webp` 281 | $('#folderIMGElementSrc').attr('src', href) 282 | $('#folderIMGElement').show() 283 | }, 284 | () => { 285 | $('#folderIMGElementSrc').attr('src','') // 更改 img src 286 | $('#folderIMGElement').hide() 287 | } 288 | ) 289 | } else { 290 | // 如果不是最後一頁,append數據 ,並綁定 scroll 事件(如果還未綁定),更新 scroll_status 291 | append_files_to_list(path, res['data']['files']) 292 | preloadImages(imageUrls) // 開始預加載封面 293 | // 資料夾預覽圖 294 | $('.clickFolder').hover( 295 | function () { 296 | href = `${this.querySelector('a.folder').href}封面.webp` 297 | $('#folderIMGElementSrc').attr('src', href) 298 | $('#folderIMGElement').show() 299 | }, 300 | () => { 301 | $('#folderIMGElementSrc').attr('src','') // 更改 img src 302 | $('#folderIMGElement').hide() 303 | } 304 | ) 305 | if (window.scroll_status.event_bound !== true) { 306 | // 綁定事件,如果還未綁定 307 | $(window).on('scroll', function () { 308 | let scrollTop = $(this).scrollTop(), 309 | scrollHeight = getDocumentHeight(), 310 | windowHeight = $(this).height() 311 | // 滾到底部 312 | if ( 313 | scrollTop + windowHeight > 314 | scrollHeight - (Os.isMobile ? 130 : 80) 315 | ) { 316 | /* 317 | 滾到底部事件觸發時,如果此時已經正在 loading 中,則忽略此次事件; 318 | 否則,去 loading,並占據 loading鎖,表明 正在 loading 中 319 | */ 320 | if (window.scroll_status.loading_lock === true) { 321 | return 322 | } 323 | window.scroll_status.loading_lock = true 324 | 325 | // 展示一個 loading spinner 326 | $( 327 | `
    ` 328 | ).insertBefore('#readme_md') 329 | mdui.updateSpinners() 330 | // mdui.mutation(); 331 | 332 | let $list = $('#list') 333 | requestListPath( 334 | path, 335 | { 336 | password: prevReqParams['password'], 337 | page_token: $list.data('nextPageToken'), 338 | // 請求下一頁 339 | page_index: $list.data('curPageIndex') + 1, 340 | }, 341 | successResultCallback, 342 | null 343 | ) 344 | } 345 | }) 346 | window.scroll_status.event_bound = true 347 | } 348 | } 349 | 350 | // loading 成功,並成功渲染了新數據之後,釋放 loading 鎖,以便能继续處理 "滾動到底部" 事件 351 | if (window.scroll_status.loading_lock === true) { 352 | window.scroll_status.loading_lock = false 353 | } 354 | } 355 | 356 | // 開始從第1頁請求數據 357 | requestListPath(path, { password }, successResultCallback, (path) => { 358 | $('#spinner').remove() 359 | let pass = prompt('目錄加密, 請輸入密碼', '') 360 | localStorage.setItem(`password${path}`, pass) 361 | if (pass != null && pass != '') { 362 | list(path) 363 | } else { 364 | history.go(-1) 365 | } 366 | }) 367 | } 368 | 369 | /** 370 | * 把請求得來的新一頁的數據追加到 list 中 371 | * @param path 路徑 372 | * @param files 請求得來的結果 373 | */ 374 | function append_files_to_list(path, files) { 375 | let $list = $('#list'), 376 | // 是最後一頁數據了嗎? 377 | is_lastpage_loaded = null === $list.data('nextPageToken'), 378 | is_firstpage = '0' == $list.data('curPageIndex'), 379 | 380 | file_count = 0, // 檔案數量 381 | 382 | html = '', 383 | targetFiles = [], 384 | 385 | className = '' 386 | 387 | for (let i in files) { 388 | let item = files[i], 389 | p = `${path + item.name}/` 390 | if (item['size'] == undefined) { 391 | item['size'] = '' 392 | } 393 | 394 | item['size'] = formatFileSize(item['size']) 395 | if (item['mimeType'] == 'application/vnd.google-apps.folder') { 396 | // 資料夾顏色 & 封面緩存 397 | if (/連載中/.test(item.name)) { 398 | className = 'updating' 399 | imageUrls.push(`${p}%E5%B0%81%E9%9D%A2.webp`) // 封面url存入陣列 400 | } else if (/完結/.test(item.name)) { 401 | className = 'finish' 402 | imageUrls.push(`${p}%E5%B0%81%E9%9D%A2.webp`) // 封面url存入陣列 403 | } else if (/R18/.test(item.name)) { 404 | className = 'r18' 405 | } else { 406 | className = '' 407 | } 408 | html += `
  • 409 |
    folder_open ${item.name}
    410 |
    ${item['size']}
    411 |
    412 |
  • ` 413 | } else { 414 | // 檔案 415 | let p = path + item.name, 416 | c = 'file' 417 | const filepath = path + item.name 418 | // 當載入完最後一頁後,才顯示 README ,否則會影響滾動事件 419 | if (is_lastpage_loaded && item.name == '!readme.md') { 420 | get_file(p, item, (data) => { 421 | markdown('#readme_md', data) 422 | }) 423 | continue 424 | } 425 | if (item.name == '!head.md') { 426 | get_file(p, item, (data) => { 427 | markdown('#head_md', data) 428 | }) 429 | continue 430 | } 431 | switch(item.name) { // 隱藏項目 432 | case '封面.webp': 433 | continue 434 | } 435 | let ext = p.split('.').pop().toLowerCase() 436 | if ( 437 | '|html|php|css|go|java|js|json|txt|sh|md|mp4|webm|avi|bmp|jpg|jpeg|png|gif|m4a|mp3|flac|wav|ogg|mpg|mpeg|mkv|rm|rmvb|mov|wmv|asf|ts|flv|pdf|'.includes( 438 | `|${ext}|` 439 | ) 440 | ) { 441 | targetFiles.push(filepath) 442 | file_count++ // 檔案數量自增 443 | p += `?a=view` 444 | c += ' view' 445 | } 446 | html += `
  • 447 |
    ${file_count}.insert_drive_file ${item.name}
    448 |
    ${item['size']}
    449 |
    450 |
  • ` 451 | } 452 | } 453 | 454 | if (targetFiles.length > 0) { 455 | let old = localStorage.getItem(path), 456 | new_children = targetFiles 457 | // 第1頁重設;否則追加 458 | if (!is_firstpage && old) { 459 | let old_children 460 | try { 461 | old_children = JSON.parse(old) 462 | if (!Array.isArray(old_children)) { 463 | old_children = [] 464 | } 465 | } catch (e) { 466 | old_children = [] 467 | } 468 | new_children = old_children.concat(targetFiles) 469 | } 470 | 471 | localStorage.setItem(path, JSON.stringify(new_children)) 472 | } 473 | 474 | // 是第1頁時,去除橫向loading條 475 | $list.html(($list.data('curPageIndex') == '0' ? '' : $list.html()) + html) 476 | // 是最後一頁時,統計並顯示出總項目數 477 | if (is_lastpage_loaded) { 478 | $('#count') 479 | .removeClass('mdui-hidden') 480 | .find('.number') 481 | .text($list.find('li.mdui-list-item').length) 482 | } 483 | } 484 | 485 | /** 486 | * 渲染搜索結果列表。有大量重複代碼,但是裡面有不一樣的邏輯,暫時先這樣分開弄吧 487 | */ 488 | function render_search_result_list() { 489 | let href = null, // 資料夾預覽圖連結 490 | cur = window.current_drive_order, // 資料夾預覽圖 (搜尋用 變量) 491 | content = ` 492 | 493 |
    494 |
      495 |
    • 496 |
      檔案名稱expand_more
      497 |
      檔案大小expand_more
      498 |
    • 499 |
    500 |
    501 |
    502 |
      503 |
    504 |

    Discord:NekoChan#2851
    返回頂部
    505 |
    506 | ` 507 | $('#content').html(content) 508 | 509 | $('#list').html( 510 | `
    ` 511 | ) 512 | $('#readme_md').hide().html('') 513 | $('#head_md').hide().html('') 514 | 515 | /** 516 | * 搜索請求成功返回數據後的回調 517 | * @param res 返回的結果(object) 518 | * @param path 請求的路徑 519 | * @param prevReqParams 請求時所用的參數 520 | */ 521 | function searchSuccessCallback(res, prevReqParams) { 522 | // 把 nextPageToken 和 currentPageIndex 暫存在 list元素 中 523 | $('#list') 524 | .data('nextPageToken', res['nextPageToken']) 525 | .data('curPageIndex', res['curPageIndex']) 526 | 527 | // 移除 loading spinner 528 | $('#spinner').remove() 529 | 530 | if (res['nextPageToken'] === null) { 531 | // 如果是最後一頁,取消綁定 scroll 事件,重置 scroll_status ,並 append 數據 532 | $(window).off('scroll') 533 | window.scroll_status.event_bound = false 534 | window.scroll_status.loading_lock = false 535 | append_search_result_to_list(res['data']['files']) 536 | // 資料夾預覽圖 537 | $('.clickFolder').hover( 538 | function () { 539 | $.post(`/${cur}:id2path`, { id: this.querySelector('a.folder').id }, (data) => { 540 | if (data) { 541 | href = `/${cur}:${data}封面.webp` // 搜尋 url + 封面.webp 542 | $('#folderIMGElementSrc').attr('src', href) 543 | $('#folderIMGElement').show() 544 | } 545 | }) 546 | }, 547 | () => { 548 | $('#folderIMGElementSrc').attr('src','') // 更改 img src 549 | $('#folderIMGElement').hide() 550 | } 551 | ) 552 | } else { 553 | // 如果不是最後一頁,append數據 ,並綁定 scroll 事件(如果還未綁定),更新 scroll_status 554 | append_search_result_to_list(res['data']['files']) 555 | // 資料夾預覽圖 556 | $('.clickFolder').hover( 557 | function () { 558 | $.post(`/${cur}:id2path`, { id: this.querySelector('a.folder').id }, (data) => { 559 | if (data) { 560 | href = `/${cur}:${data}封面.webp` // 搜尋 url + 封面.webp 561 | $('#folderIMGElementSrc').attr('src', href) 562 | $('#folderIMGElement').show() 563 | } 564 | }) 565 | }, 566 | () => { 567 | $('#folderIMGElementSrc').attr('src','') // 更改 img src 568 | $('#folderIMGElement').hide() 569 | } 570 | ) 571 | if (window.scroll_status.event_bound !== true) { 572 | // 綁定事件,如果還未綁定 573 | $(window).on('scroll', function () { 574 | let scrollTop = $(this).scrollTop(), 575 | scrollHeight = getDocumentHeight(), 576 | windowHeight = $(this).height() 577 | // 滾到底部 578 | if ( 579 | scrollTop + windowHeight > 580 | scrollHeight - (Os.isMobile ? 130 : 80) 581 | ) { 582 | /* 583 | 滾到底部事件觸發時,如果此時已經正在 loading 中,則忽略此次事件; 584 | 否則,去 loading,並占據 loading鎖,表明 正在 loading 中 585 | */ 586 | if (window.scroll_status.loading_lock === true) { 587 | return 588 | } 589 | window.scroll_status.loading_lock = true 590 | 591 | // 展示一個 loading spinner 592 | $( 593 | `
    ` 594 | ).insertBefore('#readme_md') 595 | mdui.updateSpinners() 596 | // mdui.mutation(); 597 | 598 | let $list = $('#list') 599 | requestSearch( 600 | { 601 | q: window.MODEL.q, 602 | page_token: $list.data('nextPageToken'), 603 | // 請求下一頁 604 | page_index: $list.data('curPageIndex') + 1, 605 | }, 606 | searchSuccessCallback 607 | ) 608 | } 609 | }) 610 | window.scroll_status.event_bound = true 611 | } 612 | } 613 | 614 | // loading 成功,並成功渲染了新數據之後,釋放 loading 鎖,以便能继续處理 "滾動到底部" 事件 615 | if (window.scroll_status.loading_lock === true) { 616 | window.scroll_status.loading_lock = false 617 | } 618 | } 619 | 620 | // 開始從第1頁請求數據 621 | requestSearch({ q: window.MODEL.q }, searchSuccessCallback) 622 | } 623 | 624 | /** 625 | * 追加新一頁的搜索結果 626 | * @param files 627 | */ 628 | function append_search_result_to_list(files) { 629 | let $list = $('#list'), 630 | // 是最後一頁數據了嗎? 631 | is_lastpage_loaded = null === $list.data('nextPageToken'), 632 | // let is_firstpage = '0' == $list.data('curPageIndex'); 633 | 634 | html = '', 635 | className = '' 636 | 637 | for (let i in files) { 638 | let item = files[i] 639 | if (item['size'] == undefined) { 640 | item['size'] = '' 641 | } 642 | 643 | item['size'] = formatFileSize(item['size']) 644 | if (item['mimeType'] == 'application/vnd.google-apps.folder') { 645 | // 資料夾顏色處理 646 | if (/連載中/.test(item.name)) { 647 | className = 'updating' 648 | } else if (/完結/.test(item.name)) { 649 | className = 'finish' 650 | } else if (/R18/.test(item.name)) { 651 | className = 'r18' 652 | } else { 653 | className = '' 654 | } 655 | html += `
  • 656 |
    folder_open ${item.name}
    657 |
    ${item['size']}
    658 |
    659 |
  • ` 660 | } else { 661 | let c = 'file', 662 | ext = item.name.split('.').pop().toLowerCase() 663 | switch(item.name) { // 隱藏項目 664 | case '!head.md': 665 | continue 666 | case '封面.webp': 667 | continue 668 | } 669 | if ( 670 | '|html|php|css|go|java|js|json|txt|sh|md|mp4|webm|avi|bmp|jpg|jpeg|png|gif|m4a|mp3|flac|wav|ogg|mpg|mpeg|mkv|rm|rmvb|mov|wmv|asf|ts|flv|'.includes( 671 | `|${ext}|` 672 | ) 673 | ) { 674 | c += ' view' 675 | } 676 | html += `
  • 677 |
    insert_drive_file ${item.name}
    678 |
    ${item['size']}
    679 |
    680 |
  • ` 681 | } 682 | } 683 | 684 | // 是第1頁時,去除橫向loading條 685 | $list.html(($list.data('curPageIndex') == '0' ? '' : $list.html()) + html) 686 | // 是最後一頁時,統計並顯示出總項目數 687 | if (is_lastpage_loaded) { 688 | $('#count') 689 | .removeClass('mdui-hidden') 690 | .find('.number') 691 | .text($list.find('li.mdui-list-item').length) 692 | } 693 | } 694 | 695 | /** 696 | * 搜索結果項目點擊事件 697 | * @param a_ele 點擊的元素 698 | */ 699 | function onSearchResultItemClick(a_ele) { 700 | let me = $(a_ele), 701 | can_preview = me.hasClass('view'), 702 | cur = window.current_drive_order, 703 | dialog = mdui.dialog({ 704 | title: '', 705 | content: 706 | '
    正在獲取路徑...
    ', 707 | history: false, 708 | modal: true, 709 | closeOnEsc: true, 710 | }) 711 | mdui.updateSpinners() 712 | 713 | // 請求獲取路徑 714 | $.post(`/${cur}:id2path`, { id: a_ele.id }, (data) => { 715 | if (data) { 716 | dialog.close() 717 | window.location.href = `/${cur}:${data}${can_preview ? '?a=view' : ''}` 718 | return 719 | } 720 | dialog.close() 721 | dialog = mdui.dialog({ 722 | title: '獲取目標路徑失敗', 723 | content: '該資源可能已經移除,或已移動,請通知 NekoChan#2851 解決。', 724 | history: false, 725 | modal: true, 726 | closeOnEsc: true, 727 | buttons: [{ text: '確認' }], 728 | }) 729 | }) 730 | } 731 | 732 | function get_file(path, file, callback) { 733 | // let key = `file_path_${path}${file['modifiedTime']}` 734 | let key = `file_path_${path}`, 735 | data = localStorage.getItem(key) 736 | if (data != undefined) { 737 | return callback(data) 738 | } else { 739 | $.get(path, (d) => { 740 | localStorage.setItem(key, d) 741 | callback(d) 742 | }) 743 | } 744 | } 745 | 746 | function file(path) { 747 | let name = path.split('/').pop(), 748 | ext = name.split('.').pop().toLowerCase().replace(`?a=view`, '') 749 | if ('|mkv|'.includes(`|${ext}|`)) file_mkv(path) 750 | if ('|mp4|webm|avi|mpg|mpeg|rm|rmvb|mov|wmv|asf|ts|flv|'.includes(`|${ext}|`)) file_video(path) 751 | if ('|bmp|jpg|jpeg|png|gif|'.includes(`|${ext}|`)) file_image(path) 752 | } 753 | 754 | function file_mkv(path) { 755 | const btnClass = 'mdui-btn mdui-btn-raised mdui-ripple mdui-color-theme-accent', 756 | file_name = decodeURI(path.slice(path.lastIndexOf('/') + 1, path.length)) 757 | let encoded_url = decodeURI(window.location.origin + path), 758 | playBtn = '' 759 | if (/(WIN)/i.test(navigator.userAgent)) { 760 | playBtn = `PotPlayer 串流` 761 | } else if (/(Mac)/i.test(navigator.userAgent)) { 762 | playBtn = `` 763 | } else if (/(Android)/i.test(navigator.userAgent)) { 764 | playBtn = `` 765 | playBtn += `
    ` 766 | } else if (/(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent)) { 767 | let applelink = url.replace(/(^\w+:|^)\/\//, '') 768 | playBtn = `Infuse 串流` 769 | } 770 | playBtn += `
    直連下載檔案` 771 | 772 | let content = ` 773 |
    774 |
    775 | 776 | 777 |
    778 |
    779 |
    780 | ${playBtn} 781 |
    782 | 783 |
    784 |
    785 | ` 786 | $('#content').html(content) 787 | } 788 | 789 | // Preview Video 790 | function file_video(path) { 791 | let encoded_url = decodeURI(window.location.origin + path), 792 | targetText = '' 793 | 794 | const file_name = decodeURI(path.slice(path.lastIndexOf('/') + 1, path.length)), 795 | currentPathname = window.location.pathname, 796 | lastIndex = currentPathname.lastIndexOf('/'), 797 | fatherPathname = currentPathname.slice(0, lastIndex + 1) 798 | 799 | let target_children = localStorage.getItem(fatherPathname) 800 | 801 | if (target_children) { 802 | try { 803 | target_children = JSON.parse(target_children) 804 | if (!Array.isArray(target_children)) { 805 | target_children = [] 806 | } 807 | } catch (e) { 808 | console.error(e) 809 | target_children = [] 810 | } 811 | if (target_children.length > 0 && target_children.includes(fatherPathname+file_name)) { 812 | let len = target_children.length, 813 | cur = target_children.indexOf(fatherPathname+file_name), 814 | prev_child = cur - 1 > -1 ? target_children[cur - 1] : null, 815 | next_child = cur + 1 < len ? target_children[cur + 1] : null 816 | 817 | const btnClass1 = 'mdui-btn mdui-btn-block mdui-color-theme-accent mdui-ripple' 818 | targetText = ` 819 |
    820 |
    821 |
    822 | ${prev_child ? `` 823 | : ``} 824 |
    825 |
    826 | ${next_child ? `` 827 | : ``} 828 |
    829 |
    830 |
    831 | ` 832 | } 833 | } 834 | 835 | // 按鈕樣式 836 | const btnClass2 = 'mdui-btn mdui-btn-raised mdui-ripple mdui-color-theme-accent' 837 | // WIN 串流播放器 838 | let playBtn = `PotPlayer 串流` 839 | 840 | // 進度條預覽圖切換元素 841 | let switchElement = '', 842 | previewSwitchElement = '', 843 | // 播放器 HTML 844 | player = '' 845 | 846 | // 系統檢測 847 | if (!Os.isMobile) { 848 | // 電腦播放器 HTML 849 | player = ` 850 |
    851 |
    852 | ` 853 | // MAC 串流播放器按鈕 854 | if (/(Mac)/i.test(navigator.userAgent)) { 855 | playBtn = `` 856 | } 857 | // 進度條預覽圖 初始化參數 858 | if (localStorage.getItem('previewSwitch') == null) { 859 | localStorage.setItem('previewSwitch', 'false') 860 | } 861 | // 進度條預覽圖 元素判斷 862 | if (localStorage.getItem('previewSwitch') == 'false') { 863 | previewSwitchElement = `` 864 | } else if (localStorage.getItem('previewSwitch') == 'true') { 865 | previewSwitchElement = `` 866 | } 867 | // 進度條預覽圖 HTML 868 | switchElement = ` 869 | 870 | ondemand_video 871 | 進度條預覽圖 872 | 876 | 877 | ` 878 | } else { 879 | // 移動端播放器 HTML 880 | player = ` 881 | 882 | ` 883 | // 移動端 串流播放器按鈕 884 | if (/(Android)/i.test(navigator.userAgent)) { 885 | playBtn = `` 886 | playBtn += `
    ` 887 | } else if (/(iPhone|iPad|iPod|iOS)/i.test(navigator.userAgent)) { 888 | let applelink = encoded_url.replace(/(^\w+:|^)\/\//, '') 889 | playBtn = `Infuse 串流` 890 | } 891 | } 892 | // 直連下載 893 | playBtn += `
    直連下載檔案` 894 | 895 | let content = ` 896 |
    897 |
    898 | 899 | 900 |
    901 | ${player} 902 |
    903 | ${targetText} 904 |
    905 |
    906 | ${playBtn} 907 | ${switchElement} 908 |
    909 | 910 |
    911 |
    912 | ` 913 | $('#content').html(content) 914 | 915 | if (/(WIN|Mac)/i.test(navigator.userAgent)) { 916 | $(() => { 917 | // DPlayer Script 未正常載入則刷新網頁 918 | if (!window.DPlayer) { 919 | window.location.reload() 920 | } 921 | 922 | // 進度條預覽圖 點擊事件 923 | const previewSwitch = $('#previewSwitch') 924 | previewSwitch.click(() => { 925 | if (localStorage.getItem('previewSwitch') == 'true') { 926 | localStorage.setItem('previewSwitch', 'false') 927 | window.location.reload() 928 | } else if (localStorage.getItem('previewSwitch') == 'false') { 929 | localStorage.setItem('previewSwitch', 'true') 930 | window.location.reload() 931 | } 932 | }) 933 | 934 | // 主要播放器函式(開啟預覽圖) 935 | const loadMainPlayer = () => { 936 | let currentTime = 0, // 當前播放時間 937 | oldVol = 0.5, // 初始化音量 938 | mute = false, // 靜音狀態 939 | dp = null // 重置變數 940 | 941 | // DPlayer 參數 942 | if (localStorage.getItem('previewSwitch') == 'true') { 943 | dp = new DPlayer({ // 開啟預覽圖 944 | container: $('#player')[0], 945 | theme: '#0080ff', 946 | autoplay: true, 947 | lang: 'zh-tw', 948 | mutex: false, 949 | volume: 0.5, 950 | video: { 951 | url: encoded_url, 952 | thumbnails: 953 | '//cdn.jsdelivr.net/gh/NekoChanTaiwan/NekoChan-Open-Data@1.8.6.beta2/images/fake-thumbnails.webp', 954 | }, 955 | }) 956 | } else if (localStorage.getItem('previewSwitch') == 'false') { 957 | dp = new DPlayer({ // 關閉預覽圖 958 | container: $('#player')[0], 959 | theme: '#0080ff', 960 | autoplay: true, 961 | lang: 'zh-tw', 962 | mutex: false, 963 | volume: 0.5, 964 | video: { 965 | url: encoded_url, 966 | }, 967 | }) 968 | } 969 | 970 | // 跳轉至 currentTime 971 | if (currentTime != 0) { 972 | dp.seek(currentTime) 973 | } 974 | 975 | // 紀錄已跳轉的時間 976 | dp.on('seeked', () => { 977 | currentTime = dp.video.currentTime 978 | }) 979 | 980 | // 紀錄正在跳轉的時間 981 | dp.on('seeking', () => { 982 | currentTime = dp.video.currentTime 983 | }) 984 | 985 | // 如果影片載入失敗則重新讀取 986 | dp.on('error', () => { 987 | // 紀錄載入失敗時的播放時間(如果播放時間不等於 0) 988 | if (dp.video.currentTime != 0) { 989 | currentTime = dp.video.currentTime // 紀錄載入失敗時的播放時間 990 | } 991 | loadMainPlayer() 992 | }) 993 | 994 | // 影片播放完畢 995 | dp.on('ended', () => { 996 | // 退出全螢幕 997 | dp.fullScreen.cancel('browser') 998 | }) 999 | 1000 | // 播放器載入完成 1001 | dp.on('loadedmetadata', () => { 1002 | const seekTime = dp.video.duration / 10 // 100% / 10 = 10% 1003 | $(window).unbind('keyup') 1004 | // 鍵盤快捷鍵 1005 | $(window).keyup((event) => { 1006 | if (/Numpad/.test(event.code)) { 1007 | let num = Number(event.code[6]) 1008 | dp.seek(seekTime * num) // 數字鍵跳轉 1009 | } else if (/Digit/.test(event.code)) { 1010 | let num = Number(event.code[5]) 1011 | dp.seek(seekTime * num) // 上排數字鍵跳轉 1012 | } else if (/Key/.test(event.code)) { 1013 | switch (event.code[3]) { 1014 | case 'M': // 靜音 1015 | if (mute == false) { 1016 | saveOldVol() 1017 | dp.volume(0.0, true, false) 1018 | mute = true 1019 | break 1020 | } else if (mute == true) { 1021 | dp.volume(oldVol, true, false) 1022 | mute = false 1023 | break 1024 | } 1025 | case 'X': // 下一集 1026 | $('#rightBtn').click() 1027 | break 1028 | case 'Z': // 上一集 1029 | $('#leftBtn').click() 1030 | break 1031 | case 'F': // 全螢幕 1032 | $('.dplayer-icon.dplayer-full-icon').click() 1033 | break 1034 | } 1035 | } 1036 | }) 1037 | }) 1038 | 1039 | // 紀錄當前音量 1040 | const saveOldVol = () => { 1041 | // 直接取兩值(音量) 1042 | // 50% = 0.5 1043 | let currentVol = `${String( 1044 | $('.dplayer-volume-bar-wrap').attr('data-balloon') 1045 | )}` // 假設 5% 1046 | // console.log(`current: ${currentVol}`) 1047 | if (currentVol[3] == '%') { 1048 | // 100% 1049 | oldVol = 1.0 1050 | // console.log(`oldVol: ${oldVol}`) 1051 | } else if (currentVol[2] == '%') { 1052 | // 十位數 1053 | oldVol = Number(`0.${currentVol[0]}`) 1054 | // console.log(`oldVol: ${oldVol}`) 1055 | } else if (currentVol[1] == '%') { 1056 | // 個位數(無視,設定成0.1) 1057 | oldVol = 0.1 1058 | // console.log(`oldVol: ${oldVol}`) 1059 | } 1060 | } 1061 | } 1062 | 1063 | // 載入主播放器 1064 | loadMainPlayer() 1065 | 1066 | // ================================================================================= 1067 | // 以上為主要播放器 、以下為截圖播放器 1068 | // ================================================================================= 1069 | 1070 | // 讀取截圖播放器 1071 | const loadScreenshotPlayer = () => { 1072 | let moveTimeSec = 0, // 移動時間(數字 - 單位: 秒) 1073 | oldMoveTimeSec = 0, // 上一次移動時間(數字 - 單位: 秒) 1074 | range = 5, // 移動時間範圍值 1075 | temp = null // 格式化變數 1076 | 1077 | let oldCanvas = $('#player canvas') // 舊畫布(預覽圖) 1078 | const screenshotPlayerElement = $('#screenshotPlayer')[0], 1079 | barWrap = $('#player .dplayer-bar-wrap'), // 進度條 1080 | parentNode = $('#player .dplayer-bar-preview') // 畫布(預覽圖)父節點 1081 | 1082 | screenshotPlayerElement.style.display = 'none' // 隱藏播放器 1083 | 1084 | let screenshotPlayer = null // 重置變數 1085 | screenshotPlayer = new DPlayer({ 1086 | // 截圖播放器 1087 | container: screenshotPlayerElement, 1088 | autoplay: true, 1089 | screenshot: true, 1090 | mutex: false, 1091 | video: { 1092 | url: encoded_url, 1093 | }, 1094 | }) 1095 | screenshotPlayer.volume(0, true, true) 1096 | screenshotPlayer.speed(16) // 嘗試加速讓播放器讀取更多畫面 1097 | 1098 | // 獲取時間並轉換 函式 1099 | let toSec = (stringTime) => { 1100 | temp = stringTime.split(':') 1101 | if (stringTime.length === 2) { 1102 | // 將字符串轉換成數字(單位:秒) 1103 | moveTimeSec = Number(temp) 1104 | } else if (stringTime.length === 5) { 1105 | moveTimeSec = 60 * Number(temp[0]) + Number(temp[1]) 1106 | } else if (stringTime.length === 8) { 1107 | moveTimeSec = 1108 | 3600 * Number(temp[0]) + 60 * Number(temp[1]) + Number(temp[2]) 1109 | } 1110 | seekScreenshotPlayer() //* 呼叫跳轉函式 1111 | } 1112 | 1113 | // 跳轉截圖播放器 和 click 函式 1114 | let seekScreenshotPlayer = () => { 1115 | // 目前 range = 5 1116 | // 跳轉截圖播放器(比click更容易觸發,因為讀取畫面有時需要時間) 1117 | if ( 1118 | moveTimeSec > oldMoveTimeSec + (range - 3) || 1119 | moveTimeSec < oldMoveTimeSec - (range - 3) 1120 | ) { 1121 | screenshotPlayer.seek(moveTimeSec) // 跳轉到 移動時間(數字 - 單位: 秒) 1122 | } 1123 | // 對截圖按鈕發送click(應該使用幾秒範圍,可以避免過多的click) 1124 | if ( 1125 | moveTimeSec > oldMoveTimeSec + range || 1126 | moveTimeSec < oldMoveTimeSec - range 1127 | ) { 1128 | oldCanvas = $('#player canvas') 1129 | if (oldCanvas) { 1130 | parentNode.remove(oldCanvas) // 移除現在的畫布(預覽圖) 1131 | } 1132 | $('#screenshotPlayer .dplayer-camera-icon').click() // 點擊截圖按鈕 1133 | oldMoveTimeSec = moveTimeSec // 紀錄上一次移動時間(數字 - 單位: 秒) 1134 | } 1135 | } 1136 | 1137 | // 滑鼠事件 1138 | barWrap.mousemove(() => { 1139 | toSec($('.dplayer-bar-time').html()) 1140 | }) 1141 | 1142 | // 如果影片載入失敗則重新讀取 1143 | screenshotPlayer.on('error', () => { 1144 | loadScreenshotPlayer() 1145 | }) 1146 | } 1147 | 1148 | // 進度條預覽必須啟動 才使用截圖播放器 1149 | if (localStorage.getItem('previewSwitch') == 'true') { 1150 | loadScreenshotPlayer() // 第一次載入截圖播放器 1151 | } 1152 | }) 1153 | } 1154 | 1155 | $('#leftBtn, #rightBtn').click((e) => { 1156 | let target = $(e.target) 1157 | if (['I', 'SPAN'].includes(e.target.nodeName)) { 1158 | target = $(e.target).parent() 1159 | } 1160 | const filepath = target.attr('data-filepath') 1161 | history.pushState({}, '', `${filepath}?a=view`) 1162 | file(filepath) 1163 | }) 1164 | } 1165 | 1166 | function file_image(path) { 1167 | let url = decodeURI(window.location.origin + path), 1168 | content = `
    1169 |
    1170 | 1171 |
    1172 |
    1173 |
    ` 1174 | $('#content').html(content) 1175 | } 1176 | 1177 | function formatFileSize(bytes) { 1178 | if (bytes >= 1073741824) { 1179 | bytes = `${(bytes / 1073741824).toFixed(2)} GB` 1180 | } else if (bytes >= 1048576) { 1181 | bytes = `${(bytes / 1048576).toFixed(2)} MB` 1182 | } else if (bytes >= 1024) { 1183 | bytes = `${(bytes / 1024).toFixed(2)} KB` 1184 | } else if (bytes > 1) { 1185 | bytes = `${bytes} Bytes` 1186 | } else if (bytes == 1) { 1187 | bytes = `${bytes} Byte` 1188 | } else { 1189 | bytes = ' 資料夾' 1190 | } 1191 | return bytes 1192 | } 1193 | 1194 | String.prototype.trim = function (char) { 1195 | if (char) { 1196 | return this.replace(new RegExp(`^\\${char}+|\\${char}+$`, 'g'), '') 1197 | } 1198 | return this.replace(/^\s+|\s+$/g, '') 1199 | } 1200 | 1201 | function markdown(el, data) { 1202 | if (!window.markdownit) { 1203 | window.location.reload() 1204 | } 1205 | if (window.md == undefined) { 1206 | window.md = window.markdownit() 1207 | markdown(el, data) 1208 | } else { 1209 | let html = md.render(data) 1210 | $(el).show().html(html) 1211 | } 1212 | } 1213 | 1214 | window.onpopstate = () => { 1215 | let path = window.location.pathname 1216 | render(path) 1217 | } 1218 | 1219 | $(() => { 1220 | init() 1221 | let path = window.location.pathname 1222 | render(path) 1223 | }) 1224 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const authConfig = { 2 | siteName: 'NekoChan Open Data', 3 | version: '', 4 | client_id: '', 5 | client_secret: '', 6 | refresh_token: '', 7 | /** 8 | * 設置要顯示的多個雲端硬碟;按格式添加多個 9 | * [id]: 可以是 團隊盤id、子文件夾id、或者"root"(代表個人盤根目錄) 10 | * [name]: 顯示的名稱 11 | * [user]: Basic Auth 的使用者名稱 12 | * [pass]: Basic Auth 的密碼 13 | * [protect_file_link]: Basic Auth 是否用於保護文件連結,預設值(不設置時)為 false,即不保護文件連結(方便 直鏈下載/外部播放 等) 14 | * 每個盤的 Basic Auth 都可以單獨設置。Basic Auth 默認保護該盤下所有文件夾/子文件夾路徑 15 | * 【注意】默認不保護文件連結,這樣可以方便 直鏈下載/外部播放 16 | * 如果要保護文件連結,需要將 protect_file_link 設置為 true,此時如果要進行外部播放等操作,需要將 host 替換為 user:pass@host 的 形式 17 | * 不需要 Basic Auth 的盤,保持 user 和 pass 同時為空即可。(直接不設置也可以) 18 | * 【注意】對於id設置為為子文件夾id的盤將不支持搜尋功能(不影響其他盤)。 19 | */ 20 | roots: [ 21 | { 22 | id: 'root', 23 | name: '主目錄', 24 | }, 25 | ], 26 | /** 27 | * 文件列表頁面每頁顯示的數量。【推薦設置值為 100 到 1000 之間】; 28 | * 如果設置大於1000,會導致請求 drive api 時出錯; 29 | * 如果設置的值過小,會導致文件列表頁面滾動條增量載入(分頁載入)失效; 30 | * 此值的另一個作用是,如果目錄內文件數大於此設置值(即需要多頁展示的),將會對首次列目錄結果進行快取。 31 | */ 32 | files_list_page_size: 50, 33 | /** 34 | * 搜索結果頁面每頁顯示的數量。【推薦設置值為 50 到 1000 之間】; 35 | * 如果設置大於1000,會導致請求 drive api 時出錯; 36 | * 如果設置的值過小,會導致搜索結果頁面滾動條增量載入(分頁載入)失效; 37 | * 此值的大小影響搜索操作的響應速度。 38 | */ 39 | search_result_list_page_size: 50, 40 | // 確認有 cors 用途的可以開啟 41 | enable_cors_file_down: false, 42 | /** 43 | * 上面的 basic auth 已經包含了盤內全局保護的功能。所以默認不再去認證 .password 文件內的密碼 44 | * 如果在全局認證的基礎上,仍需要給某些目錄單獨進行 .password 文件內的密碼驗證的話,將此选项設置為 true 45 | * 【注意】如果開啟了 .password 文件密碼驗證,每次列目錄都會額外增加查詢目錄內 .password 文件是否存在的開銷。 46 | */ 47 | enable_password_file_verify: false, 48 | } 49 | 50 | /** 51 | * web ui 設置 52 | */ 53 | const uiConfig = { 54 | main_color: 'blue', 55 | accent_color: 'blue', 56 | } 57 | 58 | /** 59 | * global functions 60 | */ 61 | const FUNCS = { 62 | /** 63 | * 轉換成針對Google搜索詞法相對安全的搜索關鍵字 64 | */ 65 | formatSearchKeyword: function (keyword) { 66 | let nothing = '' 67 | let space = ' ' 68 | if (!keyword) return nothing 69 | return keyword 70 | .replace(/(!=)|['"=<>/\\:]/g, nothing) 71 | .replace(/[,,|(){}]/g, space) 72 | .trim() 73 | }, 74 | } 75 | 76 | /** 77 | * global consts 78 | * @type {{folder_mime_type: string, default_file_fields: string, gd_root_type: {share_drive: number, user_drive: number, sub_folder: number}}} 79 | */ 80 | const CONSTS = new (class { 81 | default_file_fields = 82 | 'parents,id,name,mimeType,modifiedTime,createdTime,fileExtension,size' 83 | gd_root_type = { 84 | user_drive: 0, 85 | share_drive: 1, 86 | sub_folder: 2, 87 | } 88 | folder_mime_type = 'application/vnd.google-apps.folder' 89 | })() 90 | 91 | // gd instances 92 | var gds = [] 93 | 94 | function html(current_drive_order = 0, model = {}) { 95 | return ` 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | ${authConfig.siteName} 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 125 | 126 | 127 | 128 | 129 | 130 | ` 131 | } 132 | 133 | addEventListener('fetch', (e) => { 134 | e.respondWith(handleRequest(e.request)) 135 | }) 136 | 137 | /** 138 | * Fetch and log a request 139 | * @param {Request} request 140 | */ 141 | async function handleRequest(request) { 142 | if (gds.length === 0) { 143 | for (let i = 0; i < authConfig.roots.length; i++) { 144 | const gd = new googleDrive(authConfig, i) 145 | await gd.init() 146 | gds.push(gd) 147 | } 148 | // 這個操作並行,提高效率 149 | let tasks = [] 150 | gds.forEach((gd) => { 151 | tasks.push(gd.initRootType()) 152 | }) 153 | for (let task of tasks) { 154 | await task 155 | } 156 | } 157 | 158 | // 從 path 中提取 drive order 159 | // 並根據 drive order 獲取對應的 gd instance 160 | let gd, 161 | url = new URL(request.url), 162 | path = url.pathname 163 | 164 | /** 165 | * 重定向至起始頁 166 | * @returns {Response} 167 | */ 168 | function redirectToIndexPage() { 169 | return new Response('', { 170 | status: 301, 171 | headers: { Location: `${url.origin}/0:/` }, 172 | }) 173 | } 174 | 175 | if (path == '/') return redirectToIndexPage() 176 | if (path.toLowerCase() == '/favicon.ico') { 177 | // 後面可以找一個 favicon 178 | return new Response('', { status: 404 }) 179 | } 180 | 181 | // 特殊命令格式 182 | const command_reg = /^\/(?\d+):(?[a-zA-Z0-9]+)$/g 183 | const match = command_reg.exec(path) 184 | if (match) { 185 | const num = match.groups.num 186 | const order = Number(num) 187 | if (order >= 0 && order < gds.length) { 188 | gd = gds[order] 189 | } else { 190 | return redirectToIndexPage() 191 | } 192 | // basic auth 193 | for (const r = gd.basicAuthResponse(request); r; ) return r 194 | const command = match.groups.command 195 | // 搜索 196 | if (command === 'search') { 197 | if (request.method === 'POST') { 198 | // 搜索結果 199 | return handleSearch(request, gd) 200 | } else { 201 | const params = url.searchParams 202 | // 搜索頁面 203 | return new Response( 204 | html(gd.order, { 205 | q: params.get('q') || '', 206 | is_search_page: true, 207 | root_type: gd.root_type, 208 | }), 209 | { 210 | status: 200, 211 | headers: { 'Content-Type': 'text/html; charset=utf-8' }, 212 | } 213 | ) 214 | } 215 | } else if (command === 'id2path' && request.method === 'POST') { 216 | return handleId2Path(request, gd) 217 | } 218 | } 219 | 220 | // 期望的 path 格式 221 | const common_reg = /^\/\d+:\/.*$/g 222 | try { 223 | if (!path.match(common_reg)) { 224 | return redirectToIndexPage() 225 | } 226 | let split = path.split('/'), 227 | order = Number(split[1].slice(0, -1)) 228 | if (order >= 0 && order < gds.length) { 229 | gd = gds[order] 230 | } else { 231 | return redirectToIndexPage() 232 | } 233 | } catch (e) { 234 | return redirectToIndexPage() 235 | } 236 | 237 | // basic auth 238 | // for (const r = gd.basicAuthResponse(request); r;) return r; 239 | const basic_auth_res = gd.basicAuthResponse(request) 240 | 241 | path = path.replace(gd.url_path_prefix, '') || '/' 242 | if (request.method == 'POST') { 243 | return basic_auth_res || apiRequest(request, gd) 244 | } 245 | 246 | let action = url.searchParams.get('a') 247 | 248 | if (path.substr(-1) == '/' || action != null) { 249 | return ( 250 | basic_auth_res || 251 | new Response(html(gd.order, { root_type: gd.root_type }), { 252 | status: 200, 253 | headers: { 'Content-Type': 'text/html; charset=utf-8' }, 254 | }) 255 | ) 256 | } else { 257 | if (path.split('/').pop().toLowerCase() == '.password') { 258 | return basic_auth_res || new Response('', { status: 404 }) 259 | } 260 | let file = await gd.file(path) 261 | let range = request.headers.get('Range') 262 | const inline_down = 'true' === url.searchParams.get('inline') 263 | if (gd.root.protect_file_link && basic_auth_res) return basic_auth_res 264 | return gd.down(file.id, range, inline_down) 265 | } 266 | } 267 | 268 | async function apiRequest(request, gd) { 269 | let url = new URL(request.url) 270 | let path = url.pathname 271 | path = path.replace(gd.url_path_prefix, '') || '/' 272 | 273 | let option = { status: 200, headers: { 'Access-Control-Allow-Origin': '*' } } 274 | 275 | if (path.substr(-1) == '/') { 276 | let form = await request.formData() 277 | // 這樣可以提升首次列目錄時的速度。缺點是,如果password驗證失敗,也依然會產生列目錄的開銷 278 | let deferred_list_result = gd.list( 279 | path, 280 | form.get('page_token'), 281 | Number(form.get('page_index')) 282 | ) 283 | 284 | // check .password file, if `enable_password_file_verify` is true 285 | if (authConfig['enable_password_file_verify']) { 286 | let password = await gd.password(path) 287 | // console.log("dir password", password); 288 | if (password && password.replace('\n', '') !== form.get('password')) { 289 | let html = `{"error": {"code": 401,"message": "password error."}}` 290 | return new Response(html, option) 291 | } 292 | } 293 | 294 | let list_result = await deferred_list_result 295 | return new Response(JSON.stringify(list_result), option) 296 | } else { 297 | let file = await gd.file(path) 298 | let range = request.headers.get('Range') 299 | return new Response(JSON.stringify(file)) 300 | } 301 | } 302 | 303 | // 處理 search 304 | async function handleSearch(request, gd) { 305 | const option = { 306 | status: 200, 307 | headers: { 'Access-Control-Allow-Origin': '*' }, 308 | } 309 | let form = await request.formData() 310 | let search_result = await gd.search( 311 | form.get('q') || '', 312 | form.get('page_token'), 313 | Number(form.get('page_index')) 314 | ) 315 | return new Response(JSON.stringify(search_result), option) 316 | } 317 | 318 | /** 319 | * 處理 id2path 320 | * @param request 需要 id 參數 321 | * @param gd 322 | * @returns {Promise} 【注意】如果從前台接收的id代表的項目不在目標gd盤下,那麼response會返回給前台一個空字串"" 323 | */ 324 | async function handleId2Path(request, gd) { 325 | const option = { 326 | status: 200, 327 | headers: { 'Access-Control-Allow-Origin': '*' }, 328 | } 329 | let form = await request.formData() 330 | let path = await gd.findPathById(form.get('id')) 331 | return new Response(path || '', option) 332 | } 333 | 334 | class googleDrive { 335 | constructor(authConfig, order) { 336 | // 每個盤對應一個order,對應一個gd實例 337 | this.order = order 338 | this.root = authConfig.roots[order] 339 | this.root.protect_file_link = this.root.protect_file_link || false 340 | this.url_path_prefix = `/${order}:` 341 | this.authConfig = authConfig 342 | // TODO: 這些快取的失效刷新策略,後期可以制定一下 343 | // path id 344 | this.paths = [] 345 | // path file 346 | this.files = [] 347 | // path pass 348 | this.passwords = [] 349 | // id <-> path 350 | this.id_path_cache = {} 351 | this.id_path_cache[this.root['id']] = '/' 352 | this.paths['/'] = this.root['id'] 353 | /*if (this.root['pass'] != "") { 354 | this.passwords['/'] = this.root['pass']; 355 | }*/ 356 | // this.init(); 357 | } 358 | 359 | /** 360 | * 初次授權;然後獲取 user_drive_real_root_id 361 | * @returns {Promise} 362 | */ 363 | async init() { 364 | await this.accessToken() 365 | /*await (async () => { 366 | // 只獲取1次 367 | if (authConfig.user_drive_real_root_id) return; 368 | const root_obj = await (gds[0] || this).findItemById('root'); 369 | if (root_obj && root_obj.id) { 370 | authConfig.user_drive_real_root_id = root_obj.id 371 | } 372 | })();*/ 373 | // 等待 user_drive_real_root_id ,只獲取1次 374 | if (authConfig.user_drive_real_root_id) return 375 | const root_obj = await (gds[0] || this).findItemById('root') 376 | if (root_obj && root_obj.id) { 377 | authConfig.user_drive_real_root_id = root_obj.id 378 | } 379 | } 380 | 381 | /** 382 | * 獲取根目錄類型,設置到 root_type 383 | * @returns {Promise} 384 | */ 385 | async initRootType() { 386 | const root_id = this.root['id'] 387 | const types = CONSTS.gd_root_type 388 | if (root_id === 'root' || root_id === authConfig.user_drive_real_root_id) { 389 | this.root_type = types.user_drive 390 | } else { 391 | const obj = await this.getShareDriveObjById(root_id) 392 | this.root_type = obj ? types.share_drive : types.sub_folder 393 | } 394 | } 395 | 396 | /** 397 | * Returns a response that requires authorization, or null 398 | * @param request 399 | * @returns {Response|null} 400 | */ 401 | basicAuthResponse(request) { 402 | const user = this.root.user || '', 403 | pass = this.root.pass || '', 404 | _401 = new Response('Unauthorized', { 405 | headers: { 406 | 'WWW-Authenticate': `Basic realm="goindex:drive:${this.order}"`, 407 | }, 408 | status: 401, 409 | }) 410 | if (user || pass) { 411 | const auth = request.headers.get('Authorization') 412 | if (auth) { 413 | try { 414 | const [received_user, received_pass] = atob( 415 | auth.split(' ').pop() 416 | ).split(':') 417 | return received_user === user && received_pass === pass ? null : _401 418 | } catch (e) {} 419 | } 420 | } else return null 421 | return _401 422 | } 423 | 424 | async down(id, range = '', inline = false) { 425 | let url = `https://www.googleapis.com/drive/v3/files/${id}?alt=media` 426 | let requestOption = await this.requestOption() 427 | requestOption.headers['Range'] = range 428 | let res = await fetch(url, requestOption) 429 | const { headers } = (res = new Response(res.body, res)) 430 | this.authConfig.enable_cors_file_down && 431 | headers.append('Access-Control-Allow-Origin', '*') 432 | inline === true && headers.set('Content-Disposition', 'inline') 433 | return res 434 | } 435 | 436 | async file(path) { 437 | if (typeof this.files[path] == 'undefined') { 438 | this.files[path] = await this._file(path) 439 | } 440 | return this.files[path] 441 | } 442 | 443 | async _file(path) { 444 | let arr = path.split('/') 445 | let name = arr.pop() 446 | name = decodeURIComponent(name).replace(/\'/g, "\\'") 447 | let dir = arr.join('/') + '/' 448 | // console.log(name, dir); 449 | let parent = await this.findPathId(dir) 450 | // console.log(parent); 451 | let url = 'https://www.googleapis.com/drive/v3/files' 452 | let params = { includeItemsFromAllDrives: true, supportsAllDrives: true } 453 | params.q = `'${parent}' in parents and name = '${name}' and trashed = false` 454 | params.fields = 455 | 'files(id, name, mimeType, size ,createdTime, modifiedTime, iconLink, thumbnailLink)' 456 | url += '?' + this.enQuery(params) 457 | let requestOption = await this.requestOption() 458 | let response = await fetch(url, requestOption) 459 | let obj = await response.json() 460 | // console.log(obj); 461 | return obj.files[0] 462 | } 463 | 464 | // 通過reqeust cache 來快取 465 | async list(path, page_token = null, page_index = 0) { 466 | if (this.path_children_cache == undefined) { 467 | // { :[ {nextPageToken:'',data:{}}, {nextPageToken:'',data:{}} ...], ...} 468 | this.path_children_cache = {} 469 | } 470 | 471 | if ( 472 | this.path_children_cache[path] && 473 | this.path_children_cache[path][page_index] && 474 | this.path_children_cache[path][page_index].data 475 | ) { 476 | let child_obj = this.path_children_cache[path][page_index] 477 | return { 478 | nextPageToken: child_obj.nextPageToken || null, 479 | curPageIndex: page_index, 480 | data: child_obj.data, 481 | } 482 | } 483 | 484 | let id = await this.findPathId(path) 485 | let result = await this._ls(id, page_token, page_index) 486 | let data = result.data 487 | // 對有多頁的,進行快取 488 | if (result.nextPageToken && data.files) { 489 | if (!Array.isArray(this.path_children_cache[path])) { 490 | this.path_children_cache[path] = [] 491 | } 492 | this.path_children_cache[path][Number(result.curPageIndex)] = { 493 | nextPageToken: result.nextPageToken, 494 | data: data, 495 | } 496 | } 497 | 498 | return result 499 | } 500 | 501 | async _ls(parent, page_token = null, page_index = 0) { 502 | // console.log("_ls", parent); 503 | 504 | if (parent == undefined) { 505 | return null 506 | } 507 | let obj 508 | let params = { includeItemsFromAllDrives: true, supportsAllDrives: true } 509 | params.q = `'${parent}' in parents and trashed = false AND name !='.password'` 510 | params.orderBy = 'name_natural,folder,modifiedTime desc' 511 | params.fields = 512 | 'nextPageToken, files(id, name, mimeType, size , modifiedTime)' 513 | params.pageSize = this.authConfig.files_list_page_size 514 | 515 | if (page_token) { 516 | params.pageToken = page_token 517 | } 518 | let url = 'https://www.googleapis.com/drive/v3/files' 519 | url += '?' + this.enQuery(params) 520 | let requestOption = await this.requestOption() 521 | let response = await fetch(url, requestOption) 522 | obj = await response.json() 523 | 524 | return { 525 | nextPageToken: obj.nextPageToken || null, 526 | curPageIndex: page_index, 527 | data: obj, 528 | } 529 | 530 | /*do { 531 | if (pageToken) { 532 | params.pageToken = pageToken; 533 | } 534 | let url = 'https://www.googleapis.com/drive/v3/files'; 535 | url += '?' + this.enQuery(params); 536 | let requestOption = await this.requestOption(); 537 | let response = await fetch(url, requestOption); 538 | obj = await response.json(); 539 | files.push(...obj.files); 540 | pageToken = obj.nextPageToken; 541 | } while (pageToken);*/ 542 | } 543 | 544 | async password(path) { 545 | if (this.passwords[path] !== undefined) { 546 | return this.passwords[path] 547 | } 548 | 549 | // console.log("load", path, ".password", this.passwords[path]); 550 | 551 | let file = await this.file(path + '.password') 552 | if (file == undefined) { 553 | this.passwords[path] = null 554 | } else { 555 | let url = `https://www.googleapis.com/drive/v3/files/${file.id}?alt=media` 556 | let requestOption = await this.requestOption() 557 | let response = await this.fetch200(url, requestOption) 558 | this.passwords[path] = await response.text() 559 | } 560 | 561 | return this.passwords[path] 562 | } 563 | 564 | /** 565 | * 通過 id 獲取 share drive 訊息 566 | * @param any_id 567 | * @returns {Promise} 任何非正常情況都返回 null 568 | */ 569 | async getShareDriveObjById(any_id) { 570 | if (!any_id) return null 571 | if ('string' !== typeof any_id) return null 572 | 573 | let url = `https://www.googleapis.com/drive/v3/drives/${any_id}` 574 | let requestOption = await this.requestOption() 575 | let res = await fetch(url, requestOption) 576 | let obj = await res.json() 577 | if (obj && obj.id) return obj 578 | 579 | return null 580 | } 581 | 582 | /** 583 | * 搜索 584 | * @returns {Promise<{data: null, nextPageToken: null, curPageIndex: number}>} 585 | */ 586 | async search(origin_keyword, page_token = null, page_index = 0) { 587 | const types = CONSTS.gd_root_type 588 | const is_user_drive = this.root_type === types.user_drive 589 | const is_share_drive = this.root_type === types.share_drive 590 | 591 | const empty_result = { 592 | nextPageToken: null, 593 | curPageIndex: page_index, 594 | data: null, 595 | } 596 | 597 | if (!is_user_drive && !is_share_drive) { 598 | return empty_result 599 | } 600 | let keyword = FUNCS.formatSearchKeyword(origin_keyword) 601 | if (!keyword) { 602 | // 關鍵字為空,返回 603 | return empty_result 604 | } 605 | let words = keyword.split(/\s+/) 606 | let name_search_str = `name contains '${words.join( 607 | "' AND name contains '" 608 | )}'` 609 | 610 | // corpora 為 user 是個人盤 ,為 drive 是團隊盤。配合 driveId 611 | let params = {} 612 | if (is_user_drive) { 613 | params.corpora = 'user' 614 | } 615 | if (is_share_drive) { 616 | params.corpora = 'drive' 617 | params.driveId = this.root.id 618 | // This parameter will only be effective until June 1, 2020. Afterwards shared drive items will be included in the results. 619 | params.includeItemsFromAllDrives = true 620 | params.supportsAllDrives = true 621 | } 622 | if (page_token) { 623 | params.pageToken = page_token 624 | } 625 | params.q = `trashed = false AND name !='.password' AND (${name_search_str})` 626 | params.fields = 627 | 'nextPageToken, files(id, name, mimeType, size , modifiedTime)' 628 | params.pageSize = this.authConfig.search_result_list_page_size 629 | params.orderBy = 'folder,name_natural,modifiedTime desc' 630 | 631 | let url = 'https://www.googleapis.com/drive/v3/files' 632 | url += '?' + this.enQuery(params) 633 | // console.log(params) 634 | let requestOption = await this.requestOption() 635 | let response = await fetch(url, requestOption) 636 | let res_obj = await response.json() 637 | 638 | return { 639 | nextPageToken: res_obj.nextPageToken || null, 640 | curPageIndex: page_index, 641 | data: res_obj, 642 | } 643 | } 644 | 645 | /** 646 | * 一層一層的向上獲取這個文件或文件夾的上級文件夾的 file 對象。注意:會很慢!!! 647 | * 最多向上尋找到當前 gd 對象的根目錄 (root id) 648 | * 只考慮一條單獨的向上鏈。 649 | * 【注意】如果此id代表的項目不在目標gd盤下,那麼此函數會返回null 650 | * 651 | * @param child_id 652 | * @param contain_myself 653 | * @returns {Promise<[]>} 654 | */ 655 | async findParentFilesRecursion(child_id, contain_myself = true) { 656 | const gd = this 657 | const gd_root_id = gd.root.id 658 | const user_drive_real_root_id = authConfig.user_drive_real_root_id 659 | const is_user_drive = gd.root_type === CONSTS.gd_root_type.user_drive 660 | 661 | // 自下向上查詢的終點目標id 662 | const target_top_id = is_user_drive ? user_drive_real_root_id : gd_root_id 663 | const fields = CONSTS.default_file_fields 664 | 665 | // [{},{},...] 666 | const parent_files = [] 667 | let meet_top = false 668 | 669 | async function addItsFirstParent(file_obj) { 670 | if (!file_obj) return 671 | if (!file_obj.parents) return 672 | if (file_obj.parents.length < 1) return 673 | 674 | // ['','',...] 675 | let p_ids = file_obj.parents 676 | if (p_ids && p_ids.length > 0) { 677 | // its first parent 678 | const first_p_id = p_ids[0] 679 | if (first_p_id === target_top_id) { 680 | meet_top = true 681 | return 682 | } 683 | const p_file_obj = await gd.findItemById(first_p_id) 684 | if (p_file_obj && p_file_obj.id) { 685 | parent_files.push(p_file_obj) 686 | await addItsFirstParent(p_file_obj) 687 | } 688 | } 689 | } 690 | 691 | const child_obj = await gd.findItemById(child_id) 692 | if (contain_myself) { 693 | parent_files.push(child_obj) 694 | } 695 | await addItsFirstParent(child_obj) 696 | 697 | return meet_top ? parent_files : null 698 | } 699 | 700 | /** 701 | * 獲取相對於本盤根目錄的path 702 | * @param child_id 703 | * @returns {Promise} 【注意】如果此id代表的項目不在目標gd盤下,那麼此方法會返回空字串"" 704 | */ 705 | async findPathById(child_id) { 706 | if (this.id_path_cache[child_id]) { 707 | return this.id_path_cache[child_id] 708 | } 709 | 710 | const p_files = await this.findParentFilesRecursion(child_id) 711 | if (!p_files || p_files.length < 1) return '' 712 | 713 | let cache = [] 714 | // 把查出來的每一級的path和id都快取一下 715 | p_files.forEach((value, idx) => { 716 | const is_folder = 717 | idx === 0 ? p_files[idx].mimeType === CONSTS.folder_mime_type : true 718 | let path = 719 | '/' + 720 | p_files 721 | .slice(idx) 722 | .map((it) => it.name) 723 | .reverse() 724 | .join('/') 725 | if (is_folder) path += '/' 726 | cache.push({ id: p_files[idx].id, path: path }) 727 | }) 728 | 729 | cache.forEach((obj) => { 730 | this.id_path_cache[obj.id] = obj.path 731 | this.paths[obj.path] = obj.id 732 | }) 733 | 734 | /*const is_folder = p_files[0].mimeType === CONSTS.folder_mime_type; 735 | let path = '/' + p_files.map(it => it.name).reverse().join('/'); 736 | if (is_folder) path += '/';*/ 737 | 738 | return cache[0].path 739 | } 740 | 741 | // 根據id獲取file item 742 | async findItemById(id) { 743 | const is_user_drive = this.root_type === CONSTS.gd_root_type.user_drive 744 | let url = `https://www.googleapis.com/drive/v3/files/${id}?fields=${ 745 | CONSTS.default_file_fields 746 | }${is_user_drive ? '' : '&supportsAllDrives=true'}` 747 | let requestOption = await this.requestOption() 748 | let res = await fetch(url, requestOption) 749 | return await res.json() 750 | } 751 | 752 | async findPathId(path) { 753 | let c_path = '/' 754 | let c_id = this.paths[c_path] 755 | 756 | let arr = path.trim('/').split('/') 757 | for (let name of arr) { 758 | c_path += name + '/' 759 | 760 | if (typeof this.paths[c_path] == 'undefined') { 761 | let id = await this._findDirId(c_id, name) 762 | this.paths[c_path] = id 763 | } 764 | 765 | c_id = this.paths[c_path] 766 | if (c_id == undefined || c_id == null) { 767 | break 768 | } 769 | } 770 | // console.log(this.paths); 771 | return this.paths[path] 772 | } 773 | 774 | async _findDirId(parent, name) { 775 | name = decodeURIComponent(name).replace(/\'/g, "\\'") 776 | 777 | // console.log("_findDirId", parent, name); 778 | 779 | if (parent == undefined) { 780 | return null 781 | } 782 | 783 | let url = 'https://www.googleapis.com/drive/v3/files' 784 | let params = { includeItemsFromAllDrives: true, supportsAllDrives: true } 785 | params.q = `'${parent}' in parents and mimeType = 'application/vnd.google-apps.folder' and name = '${name}' and trashed = false` 786 | params.fields = 'nextPageToken, files(id, name, mimeType)' 787 | url += '?' + this.enQuery(params) 788 | let requestOption = await this.requestOption() 789 | let response = await fetch(url, requestOption) 790 | let obj = await response.json() 791 | if (obj.files[0] == undefined) { 792 | return null 793 | } 794 | return obj.files[0].id 795 | } 796 | 797 | async accessToken() { 798 | console.log('accessToken') 799 | if ( 800 | this.authConfig.expires == undefined || 801 | this.authConfig.expires < Date.now() 802 | ) { 803 | const obj = await this.fetchAccessToken() 804 | if (obj.access_token != undefined) { 805 | this.authConfig.accessToken = obj.access_token 806 | this.authConfig.expires = Date.now() + 3500 * 1000 807 | } 808 | } 809 | return this.authConfig.accessToken 810 | } 811 | 812 | async fetchAccessToken() { 813 | console.log('fetchAccessToken') 814 | const url = 'https://www.googleapis.com/oauth2/v4/token' 815 | const headers = { 816 | 'Content-Type': 'application/x-www-form-urlencoded', 817 | } 818 | const post_data = { 819 | client_id: this.authConfig.client_id, 820 | client_secret: this.authConfig.client_secret, 821 | refresh_token: this.authConfig.refresh_token, 822 | grant_type: 'refresh_token', 823 | } 824 | 825 | let requestOption = { 826 | method: 'POST', 827 | headers: headers, 828 | body: this.enQuery(post_data), 829 | } 830 | 831 | const response = await fetch(url, requestOption) 832 | return await response.json() 833 | } 834 | 835 | async fetch200(url, requestOption) { 836 | let response 837 | for (let i = 0; i < 3; i++) { 838 | response = await fetch(url, requestOption) 839 | console.log(response.status) 840 | if (response.status != 403) { 841 | break 842 | } 843 | await this.sleep(800 * (i + 1)) 844 | } 845 | return response 846 | } 847 | 848 | async requestOption(headers = {}, method = 'GET') { 849 | const accessToken = await this.accessToken() 850 | headers['authorization'] = 'Bearer ' + accessToken 851 | return { method: method, headers: headers } 852 | } 853 | 854 | enQuery(data) { 855 | const ret = [] 856 | for (let d in data) { 857 | ret.push(encodeURIComponent(d) + '=' + encodeURIComponent(data[d])) 858 | } 859 | return ret.join('&') 860 | } 861 | 862 | sleep(ms) { 863 | return new Promise(function (resolve, reject) { 864 | let i = 0 865 | setTimeout(function () { 866 | console.log('sleep' + ms) 867 | i++ 868 | if (i >= 2) reject(new Error('i>=2')) 869 | else resolve(i) 870 | }, ms) 871 | }) 872 | } 873 | } 874 | 875 | String.prototype.trim = function (char) { 876 | if (char) { 877 | return this.replace( 878 | new RegExp('^\\' + char + '+|\\' + char + '+$', 'g'), 879 | '' 880 | ) 881 | } 882 | return this.replace(/^\s+|\s+$/g, '') 883 | } 884 | -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | /* 全局字體樣是 */ 2 | * { 3 | font-family: 'Noto Sans TC', 'Noto Sans JP', 'Noto Sans SC', serif; 4 | } 5 | /* 超連結樣式 */ 6 | a { 7 | text-decoration: none; 8 | } 9 | a:link { 10 | color: rgba(255, 255, 255, 0.87); 11 | } 12 | a:visited { 13 | color: rgba(255, 255, 255, 0.87); 14 | } 15 | /* 網頁背景 */ 16 | body { 17 | margin: 0px; 18 | padding: 0px; 19 | background: url('//cdn.jsdelivr.net/gh/NekoChanTaiwan/NekoChan-Open-Data@master/images/background_3.webp'); 20 | background-attachment: fixed; 21 | background-repeat: no-repeat; 22 | background-position: center center; 23 | background-size: cover; 24 | } 25 | /* 搜尋欄 */ 26 | #search_bar { 27 | max-width: 600px; 28 | } 29 | /* 導航背景 #232427 */ 30 | .mdui-theme-primary-blue .mdui-color-theme { 31 | background-color: rgb(45 45 45 / 95%) !important; 32 | } 33 | /* 導航樣式 */ 34 | .mdui-appbar { 35 | padding-right: 8px; 36 | padding-left: 8px; 37 | margin-right: auto; 38 | margin-left: auto; 39 | } 40 | /* 列表背景 #333232 */ 41 | .mdui-container, 42 | .mdui-textfield-input { 43 | color: rgba(255, 255, 255, 0.87); 44 | background-color: rgb(45 45 45 / 95%); 45 | } 46 | /* 資料夾顏色 */ 47 | .updating { 48 | color: rgb(251 191 72 / 87%) !important; 49 | } 50 | .finish { 51 | color: rgb(255 106 106 / 87%) !important; 52 | } 53 | .r18 { 54 | color: rgb(249 67 177 / 87%) !important; 55 | } 56 | /* Other */ 57 | .mdui-appbar .mdui-toolbar { 58 | height: 56px; 59 | font-size: 1px; 60 | } 61 | .mdui-toolbar > * { 62 | padding: 0 6px; 63 | margin: 0 2px; 64 | } 65 | .mdui-toolbar > .mdui-typo-headline { 66 | padding: 0 1pc 0 0; 67 | } 68 | .mdui-toolbar > i { 69 | padding: 0; 70 | opacity: 0.5; 71 | } 72 | .mdui-toolbar > a:hover, 73 | a.active, 74 | a.mdui-typo-headline { 75 | opacity: 1; 76 | } 77 | .mdui-list-item { 78 | transition: none; 79 | } 80 | .mdui-list > .th { 81 | background-color: initial; 82 | } 83 | .mdui-list-item > a { 84 | width: 100%; 85 | line-height: 3pc; 86 | } 87 | .mdui-list-item { 88 | margin: 2px 0; 89 | padding: 0; 90 | } 91 | .mdui-toolbar > a:last-child { 92 | opacity: 1; 93 | } 94 | @media screen and (max-width: 980px) { 95 | #nav-title{ 96 | display: none; 97 | } 98 | #search_bar{ 99 | max-width: 100%; 100 | } 101 | .mdui-list-item .mdui-text-right { 102 | display: none; 103 | } 104 | .mdui-container { 105 | width: 100% !important; 106 | margin: 0; 107 | } 108 | .mdui-toolbar > .mdui-typo-headline, 109 | .mdui-toolbar > a:last-child, 110 | .mdui-toolbar > i:first-child { 111 | display: block; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/stylev2.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | font-family: 'Noto Sans TC', 'Noto Sans JP', 'Noto Sans SC', serif; 5 | } 6 | /* 超連結樣式 */ 7 | a { 8 | text-decoration: none; 9 | } 10 | a:link { 11 | color: rgba(255, 255, 255, 0.87); 12 | } 13 | a:visited { 14 | color: rgba(255, 255, 255, 0.87); 15 | } 16 | /* 網頁背景 */ 17 | body { 18 | margin: 0px; 19 | padding: 0px; 20 | background: url('//cdn.jsdelivr.net/gh/NekoChanTaiwan/NekoChan-Open-Data@1.8.2.beta16/images/background_3.webp'); 21 | background-attachment: fixed; 22 | background-repeat: no-repeat; 23 | background-position: center center; 24 | background-size: cover; 25 | } 26 | /* 導航背景 #232427 */ 27 | .mdui-theme-primary-blue .mdui-color-theme { 28 | background-color: rgb(45 45 45 / 95%) !important; 29 | } 30 | /* 導航樣式 */ 31 | .mdui-appbar { 32 | padding-right: 8px; 33 | padding-left: 8px; 34 | margin-right: auto; 35 | margin-left: auto; 36 | max-width: 1265px; 37 | } 38 | /* 列表背景 #333232 */ 39 | .mdui-container, 40 | .mdui-textfield-input { 41 | color: rgba(255, 255, 255, 0.87); 42 | background-color: rgb(45 45 45 / 95%); 43 | } 44 | /* 特殊狀態 */ 45 | .updating { 46 | color: rgb(251 191 72 / 87%) !important; 47 | } 48 | .finish { 49 | color: rgb(255 106 106 / 87%) !important; 50 | } 51 | .r18 { 52 | color: rgb(249 67 177 / 87%) !important; 53 | } 54 | /* 海報牆模式 */ 55 | .folder-items { 56 | width: 19%; 57 | height: 350px; 58 | margin: 0 1px 10px 1px; 59 | text-decoration: none; 60 | } 61 | .folder-div { 62 | height: 100%; 63 | position: relative; 64 | display: inline-block; 65 | } 66 | .folder-img { 67 | width: 100%; 68 | height: 100%; 69 | object-fit: cover 70 | } 71 | .folder-text { 72 | position: absolute; 73 | bottom: 0; 74 | left: 0; 75 | white-space: initial; 76 | overflow: hidden; 77 | text-overflow: ellipsis; 78 | display: -webkit-box; 79 | -webkit-line-clamp: 2; 80 | -webkit-box-orient: vertical; 81 | color: white; 82 | width: 100%; 83 | height: auto; 84 | text-align: center; 85 | padding: 5px 0 0 0; 86 | background: linear-gradient(to bottom, #000000a3 80%, black 120%) 87 | } 88 | @media (max-width: 1199px) { 89 | .folder-items { 90 | width: 25%; 91 | } 92 | } 93 | @media (max-width: 991px) { 94 | .folder-items { 95 | width: 35%; 96 | height: 250px; 97 | } 98 | } 99 | @media (max-width: 767.9px) { 100 | .folder-items { 101 | width: 40%; 102 | } 103 | } 104 | /* Other */ 105 | .mdui-appbar .mdui-toolbar { 106 | height: 56px; 107 | font-size: 1px; 108 | } 109 | .mdui-toolbar > * { 110 | padding: 0 6px; 111 | margin: 0 2px; 112 | } 113 | .mdui-toolbar > .mdui-typo-headline { 114 | padding: 0 1pc 0 0; 115 | } 116 | .mdui-toolbar > i { 117 | padding: 0; 118 | opacity: 0.5; 119 | } 120 | .mdui-toolbar > a:hover, 121 | a.active, 122 | a.mdui-typo-headline { 123 | opacity: 1; 124 | } 125 | .mdui-list-item { 126 | transition: none; 127 | } 128 | .mdui-list > .th { 129 | background-color: initial; 130 | } 131 | .mdui-list-item > a { 132 | width: 100%; 133 | line-height: 3pc; 134 | } 135 | .mdui-list-item { 136 | margin: 2px 0; 137 | padding: 0; 138 | } 139 | .mdui-toolbar > a:last-child { 140 | opacity: 1; 141 | } 142 | @media screen and (max-width: 980px) { 143 | .mdui-list-item .mdui-text-right { 144 | display: none; 145 | } 146 | .mdui-container { 147 | width: 100% !important; 148 | margin: 0; 149 | } 150 | .mdui-toolbar > .mdui-typo-headline, 151 | .mdui-toolbar > a:last-child, 152 | .mdui-toolbar > i:first-child { 153 | display: block; 154 | } 155 | } 156 | --------------------------------------------------------------------------------