嗯……我写这干啥??
https://www.biliplus.com/manga/
web用阅读器,给ipad看漫画用的
shit title placeholder |
嗯……我写这干啥??
https://www.biliplus.com/manga/
web用阅读器,给ipad看漫画用的
<?php | |
if (empty($_GET['mangaid']) || !preg_match('(^\d+$)', $_GET['mangaid'])) { | |
errordie('无效id'); | |
} | |
$curl = curl_init(); | |
if (isset($_GET['unlock'])) { | |
$unlocks = explode(',', $_GET['unlock']); | |
unset($_GET['unlock']); | |
$returnUrlParam = http_build_query($_GET); | |
foreach ($unlocks as $id) { | |
$unlockResult = json_decode(cget('http://manga.bilibili.com/twirp/comic.v1.Comic/UnlockComicAlbum', [ | |
'curl' => $curl, | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'id'=>$id, | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'version'=>APPVER | |
], $appsecret) | |
]), true); | |
if ($unlockResult['code'] !== 0) { | |
$msg = '解锁特典'.$id.'出错: ['.$unlockResult['code'].']'.$unlockResult['msg']; | |
break; | |
} | |
} | |
header('Set-Cookie: manga_album_unlock_message='. urlencode(isset($msg) ? $msg : '已解锁') . '; Max-Age=30; path=/manga/; secure; HttpOnly'); | |
header('Location: ?'.$returnUrlParam, true, 302); | |
exit; | |
} | |
chdir(__DIR__); | |
$idxDb = new PDO('sqlite:index.db'); | |
$albums = cget('http://manga.bilibili.com/twirp/comic.v1.Comic/GetComicAlbumPlus', [ | |
'curl' => $curl, | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'comic_id'=>$_GET['mangaid'], | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'version'=>APPVER | |
], $appsecret) | |
]); | |
if (isset($_GET['api'])) { | |
header('Content-Type: application/json; charset="UTF-8"'); | |
echo $albums; exit; | |
} | |
if (!empty($_COOKIE['manga_album_unlock_message'])) { | |
$unlockMessage = $_COOKIE['manga_album_unlock_message']; | |
header('Set-Cookie: manga_album_unlock_message=; Max-Age=0; path=/manga/; secure; HttpOnly'); | |
} | |
$albums = json_decode($albums, true); | |
if ($albums['code'] !== 0) { | |
errordie('获取特典列表出错: ['.$albums['code'].']'.$albums['msg']); | |
} | |
if (empty($albums['data']['list'])) { | |
errordie('本作品无特典'); | |
} | |
$detail = cget('http://manga.bilibili.com/twirp/comic.v2.Comic/ComicDetail', [ | |
'curl' => $curl, | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'comic_id'=>$_GET['mangaid'], | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'version'=>APPVER | |
], $appsecret) | |
]); | |
$detail = json_decode($detail, true); | |
if ($detail['code'] !== 0) { | |
errordie('获取漫画信息出错: ['.$detail['code'].']'.$detail['msg']); | |
} | |
$detail = $detail['data']; | |
$epMap = []; | |
$epUnlockedList = []; | |
foreach ($detail['ep_list'] as $ep) { | |
$epMap[$ep['id']] = [!$ep['is_locked'], $ep['short_title']]; | |
} | |
$batchUnlock = []; | |
$albumInfoEmptyStmt = $idxDb->prepare('INSERT OR IGNORE INTO album_info (id,type,manga_id,pic_hash,title,detail) VALUES (?,?,?,0,?,?)'); | |
$albumInfoStmt = $idxDb->prepare('REPLACE INTO album_info (id,type,manga_id,pic_hash,title,detail) VALUES (?,?,?,?,?,?)'); | |
$picDataInsertStmt = $idxDb->prepare('INSERT OR IGNORE INTO album_pic_data (album_id,hash,data) VALUES (?,?,CAST(? AS BLOB))'); | |
$idxDb->beginTransaction(); | |
foreach ($albums['data']['list'] as &$album) { | |
if ($album['isLock']) { | |
$albumInfoEmptyStmt->execute([$album['item']['id'], $album['item']['type'], $_GET['mangaid'], $album['item']['title'], $album['item']['detail']]); | |
if (in_array($album['item']['type'], [4, 5])) { | |
$unlockCount = 0; $remaining = []; | |
foreach ($album['item']['item_infos'] as $requiredItem) { | |
if (in_array($requiredItem['id'], $album['unlocked_item_ids']) !== false) $unlockCount++; | |
else $remaining[] = $requiredItem['title']; | |
} | |
$album['__unlockCount'] = $unlockCount; | |
$album['__remaining'] = $remaining; | |
if (empty($remaining)) $batchUnlock[] = $album['item']['id']; | |
} | |
} else { | |
$picPaths = array_map(function ($p) { | |
preg_match('(//[^/]+([^\?@]+))', $p, $m); | |
return $m[1]; | |
}, $album['item']['pic']); | |
$picPaths = json_encode($picPaths, JSON_UNESCAPED_SLASHES); | |
$picPathsData = brotli_compress($picPaths, 4); | |
$hash = crc32($picPathsData); | |
$albumInfoStmt->execute([$album['item']['id'], $album['item']['type'], $_GET['mangaid'], $hash, $album['item']['title'], $album['item']['detail']]); | |
$picDataInsertStmt->execute([$album['item']['id'], $hash, $picPathsData]); | |
} | |
} | |
$idxDb->commit(); | |
unset($album); | |
?> | |
<!DOCTYPE html><html> | |
<head> | |
<meta charset="UTF-8" name="viewport" content="width=device-width,user-scalable=no"> | |
<meta name="format-detection" content="telephone=no" /> | |
<meta name="referrer" content="never"> | |
<title>特典 - <?php echo $detail['title'];?> - 书籍 - bilibili漫画 阅读器 - BiliPlus</title> | |
<script src="materialize.min.js"></script> | |
<link rel="stylesheet" href="materialize.min.css" /> | |
<style> | |
body {width:95%;max-width:700px;margin:5px auto;background:#EFEFF4;cursor:default} | |
.album{clear:both} | |
@media (prefers-color-scheme: dark) { | |
body {background:black;color:#DDD} | |
.card {background:#1c1c1c} | |
img {filter:brightness(0.8)} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="col s12"> | |
<?php if (isset($unlockMessage)) { ?> | |
<div class="card horizontal"> | |
<div class="card-stacked"> | |
<div class="card-content"> | |
<p><?php echo $unlockMessage;?></p> | |
</div> | |
</div> | |
</div> | |
<hr> | |
<?php } ?> | |
<h4>特典列表 - <?php echo $detail['title'];?></h4> | |
<?php | |
if (!empty($batchUnlock)) { | |
?> | |
<p><a href="?<?php echo 'act=album&mangaid='.$_GET['mangaid'].'&unlock='.implode(',', $batchUnlock)?>" class="waves-effect waves-light btn">一键解锁</a></p> | |
<?php | |
} | |
foreach ($albums['data']['list'] as $album) { ?> | |
<div class="album" id="album-<?php echo $album['item']['id']?>"> | |
<hr> | |
<img style="float:right;height:200px" alt="特典预览" src="<?php echo wrapPicUrl(str_replace('http:','',$album['item']['cover']).'@400h.jpg');?>"> | |
<h5><?php echo $album['item']['title']?></h5> | |
<h6><?php echo $album['item']['detail']?>(id <?php echo $album['item']['id']?>)</h6> | |
<p>共 <?php echo $album['item']['pic_num']?> 页,<?php echo $album['item']['online_time']?> 至 <?php echo $album['item']['offline_time']?></p> | |
<?php if ($album['isLock']) { | |
switch ($album['item']['type']) { | |
case 1: { | |
?> | |
<p> | |
<div>解锁条件:在本漫画投喂达<?php echo $album['item']['num']?>漫币获得</div> | |
</p> | |
<p><a href="?<?php echo 'act=album&mangaid='.$_GET['mangaid'].'&unlock='.$album['item']['id']?>" class="waves-effect waves-light btn">解锁</a> <a href="?act=read_album&albumid=<?php echo $album['item']['id']?>" class="waves-effect waves-light btn" target="_manga_view">查看</a></p> | |
<?php break; | |
} | |
case 2: { | |
?> | |
<p> | |
<div>解锁条件:在本漫画消费达<?php echo $album['item']['num']?>漫币获得</div> | |
</p> | |
<p><a href="?<?php echo 'act=album&mangaid='.$_GET['mangaid'].'&unlock='.$album['item']['id']?>" class="waves-effect waves-light btn">解锁</a> <a href="?act=read_album&albumid=<?php echo $album['item']['id']?>" class="waves-effect waves-light btn" target="_manga_view">查看</a></p> | |
<?php break; | |
} | |
case 3: { | |
?> | |
<p> | |
<div>解锁条件:参与活动<a href="<?php echo $album['item']['activity_url']?>" target="_blank"><?php echo $album['item']['activity_name']?></a></div> | |
</p> | |
<p><a href="?<?php echo 'act=album&mangaid='.$_GET['mangaid'].'&unlock='.$album['item']['id']?>" class="waves-effect waves-light btn">解锁</a> <a href="?act=read_album&albumid=<?php echo $album['item']['id']?>" class="waves-effect waves-light btn" target="_manga_view">查看</a></p> | |
<?php break; | |
} | |
case 4: { | |
$unlockRequirement = count($album['item']['item_ids']); | |
$unlockCount = $album['__unlockCount']; | |
$remaining = $album['__remaining']; | |
?> | |
<p> | |
<div>解锁条件:<?php echo "$unlockCount/$unlockRequirement 已购买"?></div> | |
<?php if (!empty($remaining)) { ?> | |
<div>还需购买章节 <?php echo implode('、', $remaining) ?></div> | |
<?php } ?> | |
</p> | |
<p><a href="?<?php echo 'act=album&mangaid='.$_GET['mangaid'].'&unlock='.$album['item']['id']?>" class="waves-effect waves-light btn<?php if (!empty($remaining)) echo ' disabled' ?>">解锁</a> <a href="?act=read_album&albumid=<?php echo $album['item']['id']?>" class="waves-effect waves-light btn" target="_manga_view">查看</a></p> | |
<?php break; | |
} | |
case 5: { | |
$unlockRequirement = count($album['item']['item_ids']); | |
$unlockCount = $album['__unlockCount']; | |
$remaining = $album['__remaining']; | |
?> | |
<p> | |
<div>解锁条件:<?php echo "$unlockCount/$unlockRequirement 已购买"?></div> | |
<?php if (!empty($remaining)) { ?> | |
<div>还需购买 <?php echo implode('、', $remaining) ?></div> | |
<?php } ?> | |
</p> | |
<p><a href="?<?php echo 'act=album&mangaid='.$_GET['mangaid'].'&unlock='.$album['item']['id']?>" class="waves-effect waves-light btn<?php if (!empty($remaining)) echo ' disabled' ?>">解锁</a> <a href="?act=read_album&albumid=<?php echo $album['item']['id']?>" class="waves-effect waves-light btn" target="_manga_view">查看</a></p> | |
<?php break; | |
} | |
default: { | |
?> | |
<p> | |
<div>解锁条件:解锁类型 [<?php echo $album['item']['type']?>]</div> | |
</p> | |
<p><a href="?<?php echo 'act=album&mangaid='.$_GET['mangaid'].'&unlock='.$album['item']['id']?>" class="waves-effect waves-light btn disabled">解锁</a> <a href="?act=read_album&albumid=<?php echo $album['item']['id']?>" class="waves-effect waves-light btn" target="_manga_view">查看</a></p> | |
<?php break; | |
} | |
} | |
} else { ?> | |
<p>已解锁(第<?php echo $album['item']['rank']?>位解锁)</p> | |
<p><a href="?act=read_album&albumid=<?php echo $album['item']['id']?>" class="waves-effect waves-light btn" target="_manga_view">查看</a></p> | |
<?php } ?> | |
</div> | |
<?php } ?> | |
</div> | |
</body> | |
</html> |
<?php | |
if (empty($_GET['mangaid']) || !preg_match('(^\d+$)', $_GET['mangaid'])) { | |
errordie('无效id'); | |
} | |
chdir(__DIR__); | |
$idxDb = new PDO('sqlite:index.db'); | |
$detail = cget('http://manga.bilibili.com/twirp/comic.v2.Comic/ComicDetail', [ | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'comic_id'=>$_GET['mangaid'], | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'version'=>APPVER | |
], $appsecret) | |
]); | |
$detail = json_decode($detail, true); | |
if ($detail['code'] !== 0) { | |
errordie('获取出错: ['.$detail['code'].']'.$detail['msg']); | |
} | |
$detail = $detail['data']; | |
usort($detail['ep_list'], function ($a, $b) {return $a['ord'] - $b['ord'];}); | |
$store_stmt = $idxDb->prepare('REPLACE INTO index_data (epid,mangaid,hash) VALUES (?,?,?)'); | |
$history = $idxDb->prepare('INSERT OR IGNORE INTO index_data_history (epid,mangaid,hash,data,time) VALUES (?,?,?,CAST(? AS BLOB),?)'); | |
$hashChk = $idxDb->prepare('SELECT count(epid) FROM index_data WHERE epid=? AND hash=?'); | |
$epChk = $idxDb->prepare('SELECT count(epid) FROM index_data WHERE epid=?'); | |
if ($detail['discount_type'] == 2 && $detail['discount'] == 0) { | |
$purchasedEpList = $detail['ep_list']; | |
} else { | |
$purchasedEpList = array_filter($detail['ep_list'], function ($i) {return $i['is_in_free'] || !$i['is_locked'];}); | |
} | |
header('Content-Encoding: identity'); | |
?> | |
<!DOCTYPE html><html> | |
<head> | |
<meta charset="UTF-8" name="viewport" content="width=device-width,user-scalable=no"> | |
<meta name="format-detection" content="telephone=no" /> | |
<meta name="referrer" content="never"> | |
<title><?php echo $detail['title'];?> - 书籍 - bilibili漫画 阅读器 - BiliPlus</title> | |
<script src="materialize.min.js"></script> | |
<link rel="stylesheet" href="materialize.min.css" /> | |
<style> | |
body {width:95%;max-width:700px;margin:5px auto;background:#EFEFF4;cursor:default} | |
@media (prefers-color-scheme: dark) { | |
body {background:black;color:#DDD} | |
img {filter:brightness(0.8)} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="col s12"> | |
<h3><?php echo $detail['title'];?></h3> | |
<p>共计 <?php echo count($detail['ep_list'])?> 话,可访问共 <?php echo count($purchasedEpList)?> 话</p> | |
<div style="display:flex;flex-direction:column-reverse"><div style="display:flex;flex-direction:column-reverse"> | |
<?php | |
ob_flush(); | |
flush(); | |
include_once 'batch_index_private.php'; | |
$i = 0; | |
$total = count($purchasedEpList); | |
set_time_limit(0); | |
$apiCurl = curl_init(); | |
$idxCurl = curl_init(); | |
foreach ($purchasedEpList as $ep) { | |
$i++; | |
if (isset($_GET['skip_cached'])) { | |
$epChk->execute([$ep['id']]); | |
if ($epChk->fetch()[0] == 1) continue; | |
} | |
echo "\n <div>".$i.'/'.$total.' '.$ep['id'].' '.$ep['short_title'].'...'; | |
$idxUrl = json_decode(cget('http://manga.bilibili.com/twirp/comic.v1.Comic/GetImageIndex', [ | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'device'=>'phone', | |
'ep_id'=>$ep['id'], | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'version'=>APPVER, | |
], $appsecret), | |
'curl'=>$apiCurl | |
]), true); | |
if ($idxUrl['code'] !== 0) { | |
echo '获取索引出错: ['.$idxUrl['code'].']'.$idxUrl['msg'].'</div>'; | |
continue; | |
} | |
$encryptedIndexData = cget(str_replace('https:', 'http:', wrapPicUrl($idxUrl['data']['host'] . $idxUrl['data']['path'])), ['curl'=>$idxCurl]); | |
preg_match('(/manga/(\d+)/(\d+)/data\.index)', $idxUrl['data']['path'], $idMatch); | |
$realmangaid = $idMatch[1]; | |
$realepid = $idMatch[2]; | |
$indexDataStr = decryptBiliComicIndex($encryptedIndexData, $realmangaid, $realepid); | |
if (!$indexDataStr) { | |
echo '下载索引出错</div>'; | |
continue; | |
} | |
$indexData = @json_decode($indexDataStr, true); | |
if ($indexData == NULL) { | |
echo '下载索引出错</div>'; | |
continue; | |
} | |
$data = brotli_compress($indexDataStr, 9); | |
$hash = crc32($data); | |
$hashChk->execute([$ep['id'], $hash]); | |
$updated = $hashChk->fetch()[0] != 1; | |
$retry = 0; | |
do { | |
try { | |
$idxDb->beginTransaction(); | |
break; | |
} catch (Exception $e) { | |
usleep(250000); | |
if (++$retry > 3) { | |
echo "DB异常</div>"; | |
break 2; | |
} | |
$idxDb = new PDO('sqlite:index.db'); | |
$store_stmt = $idxDb->prepare('REPLACE INTO index_data (epid,mangaid,hash) VALUES (?,?,?)'); | |
$history = $idxDb->prepare('INSERT OR IGNORE INTO index_data_history (epid,mangaid,hash,data,time) VALUES (?,?,?,CAST(? AS BLOB),?)'); | |
$hashChk = $idxDb->prepare('SELECT count(epid) FROM index_data WHERE epid=? AND hash=?'); | |
$epChk = $idxDb->prepare('SELECT count(epid) FROM index_data WHERE epid=?'); | |
} | |
} while (1); | |
$store_stmt->execute([$realepid, $realmangaid, $hash]); | |
$history->execute([$realepid, $realmangaid, $hash, $data, time()]); | |
$idxDb->prepare('REPLACE INTO index_path (epid,path) VALUES (?,?)')->execute([$ep['id'], explode('?', $idxUrl['data']['path'])[0]]); | |
$idxDb->commit(); | |
if ($updated) echo '更新'.$idxUrl['data']['last_modified']; | |
echo "完成</div>"; | |
@ob_flush();@flush(); | |
} | |
?> | |
</div> | |
<p><a href="javascript:window.close()" class="col s4 waves-effect waves-light btn">关闭</a></p> | |
</div></div> | |
</body> | |
</html> | |
<?php | |
function decryptBiliComicIndex($data, $mangaid, $epid) { | |
if (substr($data, 0, 9) !== 'BILICOMIC') { | |
return false; | |
} | |
$body = substr($data, 9); | |
$size = strlen($body); | |
$out = str_repeat("\0", $size); | |
$key = [ | |
$epid & 0xff, | |
$epid >> 8 & 0xff, | |
$epid >> 16 & 0xff, | |
$epid >> 24 & 0xff, | |
$mangaid & 0xff, | |
$mangaid >> 8 & 0xff, | |
$mangaid >> 16 & 0xff, | |
$mangaid >> 24 & 0xff | |
]; | |
for ($i = 0; $i < $size; $i++) { | |
$byte = ord($body[$i]); | |
$byte ^= ($key[($i) % 8]); | |
$out[$i] = chr($byte); | |
} | |
return readBiliComicIndexZip(new MemoryStream($out)); | |
} | |
function readBiliComicIndexZip(MemoryStream $out) { | |
$out->littleEndian = true; | |
$out->position = $out->size - 22; | |
$signature = $out->readData(4); | |
$num = $out->short; | |
$start = $out->short; | |
$numRecords = $out->short; | |
$total = $out->short; | |
$cdsize = $out->long; | |
$cdoffset = $out->long; | |
$commentlen = $out->short; | |
$out->position = $cdoffset + 4 + 2 * 6; | |
$crc = $out->ulong; | |
$compressedSize = $out->long; | |
$uncompressedSize = $out->long; | |
$out->position = 26; | |
$fnameLen = $out->short; | |
$extraLen = $out->short; | |
$out->position += $fnameLen + $extraLen; | |
$compressedData = $out->readData($compressedSize); | |
$data = gzinflate($compressedData); | |
if (crc32($data) !== $crc) { | |
return false; | |
} | |
return $data; | |
} | |
abstract class Stream { | |
abstract protected function read($length); | |
abstract public function seek($position); | |
abstract public function position(); | |
abstract protected function getPos(); | |
abstract protected function setPos($pos); | |
public function __get($name) { | |
switch($name) { | |
case 'position': return $this->getPos(); | |
case 'bool': return $this->readBoolean(); | |
case 'byte': return $this->readData(1); | |
case 'short': return $this->readInt16(); | |
case 'ushort': return $this->readUint16(); | |
case 'long': return $this->readInt32(); | |
case 'ulong': return $this->readUint32(); | |
case 'longlong': return $this->readInt64(); | |
case 'ulonglong': return $this->readUint64(); | |
case 'float': return $this->readFloat(); | |
case 'double': return $this->readDouble(); | |
case 'string': return $this->readStringToNull(); | |
case 'line': return $this->readStringToReturn(); | |
default: throw new Exception("Access undefined field ${name} of class ".get_class($this)); | |
} | |
} | |
public function __set($name, $val) { | |
switch($name) { | |
case 'position': return $this->setPos($val); | |
default: throw new Exception("Assign value to undefined field ${name} of class ".get_class($this)); | |
} | |
} | |
public $littleEndian = false; | |
public $size; | |
public function readStringToNull() { | |
$s = ''; | |
while (ord($char = $this->read(1)) != 0) { | |
$s .= $char; | |
} | |
return $s; | |
} | |
public function readStringAt($pos) { | |
$current = $this->position; | |
$this->position = $pos; | |
$data = $this->string; | |
$this->position = $current; | |
return $data; | |
} | |
public function readStringToReturn() { | |
$s = ''; | |
while ($this->position < $this->size && ($char = $this->read(1)) != "\n") { | |
$s .= $char; | |
} | |
return trim($s,"\r"); | |
} | |
public function readBoolean() { | |
return ord($this->byte)>0; | |
} | |
public function readInt16() { | |
$uint = $this->readUint16(); | |
$sint = unpack('s', pack('S', $uint))[1]; | |
return $sint; | |
} | |
public function readUint16() { | |
$int = $this->read(2); | |
if (strlen($int) != 2) return 0; | |
return unpack($this->littleEndian?'v':'n', $int)[1]; | |
} | |
public function readInt32() { | |
$uint = $this->readUint32(); | |
$sint = unpack('l', pack('L', $uint))[1]; | |
return $sint; | |
} | |
public function readUint32() { | |
$int = $this->read(4); | |
if (strlen($int) != 4) return 0; | |
return unpack($this->littleEndian?'V':'N', $int)[1]; | |
} | |
public function readInt64() { | |
$uint = $this->readUint64(); | |
$sint = unpack('q', pack('Q', $uint))[1]; | |
return $sint; | |
} | |
public function readUint64() { | |
$int = $this->read(8); | |
if (strlen($int) != 8) return 0; | |
return unpack($this->littleEndian?'P':'J', $int)[1]; | |
} | |
public function readFloat() { | |
$int = $this->read(4); | |
if (strlen($int) != 4) return 0; | |
if (!$this->littleEndian) $int = $int[3].$int[2].$int[1].$int[0]; | |
return unpack(/*$this->littleEndian?'g':'G'*/ 'f', $int)[1]; | |
} | |
public function readDouble() { | |
$int = $this->read(8); | |
if (strlen($int) != 8) return 0; | |
if (!$this->littleEndian) $int = $int[7].$int[6].$int[5].$int[4].$int[3].$int[2].$int[1].$int[0]; | |
return unpack(/*$this->littleEndian?'e':'E'*/ 'd', $int)[1]; | |
} | |
public function readData($size) { | |
if ($size <= 0) return ''; | |
return $this->read($size); | |
} | |
public function readDataAt($pos, $size) { | |
$current = $this->position; | |
$this->position = $pos; | |
$data = $this->readData($size); | |
$this->position = $current; | |
return $data; | |
} | |
public function alignStream($alignment) { | |
$mod = $this->position % $alignment; | |
if ($mod != 0) { | |
$this->position += $alignment - $mod; | |
} | |
} | |
public function readAlignedString($len) { | |
$string = $this->readData($len); | |
$this->alignStream(4); | |
return $string; | |
} | |
} | |
class MemoryStream extends Stream { | |
private $data; | |
private $offset; | |
function __construct($data) { | |
$this->data = $data; | |
$this->size = strlen($data); | |
} | |
function __destruct() { | |
$this->data = NULL; | |
} | |
protected function read($length) { | |
$data = substr($this->data, $this->offset, $length); | |
$this->offset += $length; | |
return $data; | |
} | |
public function seek($position) { | |
$this->offset = $position; | |
} | |
public function write($newData) { | |
$this->data .= $newData; | |
$this->size += strlen($newData); | |
} | |
public function position() { | |
return $this->offset; | |
} | |
protected function getPos() { | |
return $this->offset; | |
} | |
protected function setPos($pos) { | |
$this->offset = $pos; | |
} | |
} |
<?php | |
if (empty($_GET['mangaid']) || !preg_match('(^\d+$)', $_GET['mangaid'])) { | |
errordie('无效id'); | |
} | |
chdir(__DIR__); | |
$idxDb = new PDO('sqlite:index.db'); | |
$detail = cget('http://manga.bilibili.com/twirp/comic.v2.Comic/ComicDetail', [ | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'comic_id'=>$_GET['mangaid'], | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'version'=>APPVER | |
], $appsecret) | |
]); | |
if (isset($_GET['api'])) { | |
header('Content-Type: application/json; charset="UTF-8"'); | |
echo $detail; exit; | |
} | |
$detail = json_decode($detail, true); | |
if ($detail['code'] !== 0) { | |
errordie('获取出错: ['.$detail['code'].']'.$detail['msg']); | |
} | |
$detail = $detail['data']; | |
usort($detail['ep_list'], function ($a, $b) {return $a['ord'] > $b['ord'] ? 1 : -1;}); | |
if ($detail['discount_type'] == 2 && $detail['discount'] == 0) { | |
foreach ($detail['ep_list'] as &$ep) { | |
$ep['is_locked'] = false; | |
} | |
unset($ep); | |
} | |
$idxDb->prepare('REPLACE INTO manga_info (id,title,list) VALUES (?,?,CAST(? AS BLOB))')->execute([ | |
$detail['id'], | |
$detail['title'], | |
brotli_compress(json_encode($detail['ep_list'], JSON_UNESCAPED_UNICODE+JSON_UNESCAPED_SLASHES), 9) | |
]); | |
$discount_desc = []; | |
switch ($detail['discount_type']) { | |
case 0: { break; } | |
case 1: { | |
$discount_desc[] = '全本'.($detail['discount']/10).'折'; | |
break; | |
} | |
case 2: { | |
if ($detail['discount']) { | |
$discount_desc[] = '单章'.($detail['discount']/10).'折'; | |
} else { | |
$discount_desc[] = '限时免费'; | |
} | |
break; | |
} | |
case 3: { | |
$discount_desc[] = '部分限时免费'; | |
break; | |
} | |
} | |
if ($detail['ep_discount_type']) { | |
$discount_desc[] = '单章购买优惠'; | |
} | |
if ($detail['batch_discount_type']) { | |
$discount_desc[] = '批量购买优惠'; | |
} | |
function prettySize(int $s) { | |
if ($s <= 0) return null; | |
$units = ['B','KB','MB','GB']; | |
$unit = 0; | |
if ($s < 1001) return $s.'B'; | |
while ($s > 1000) { | |
$unit++; | |
$s/=1024; | |
} | |
return number_format($s, 2, '.', '') . $units[$unit]; | |
} | |
?> | |
<!DOCTYPE html><html> | |
<head> | |
<meta charset="UTF-8" name="viewport" content="width=device-width,user-scalable=no"> | |
<meta name="format-detection" content="telephone=no" /> | |
<meta name="referrer" content="never"> | |
<title><?php echo $detail['title'];?> - 书籍 - bilibili漫画 阅读器 - BiliPlus</title> | |
<script src="materialize.min.js"></script> | |
<link rel="stylesheet" href="materialize.min.css" /> | |
<style> | |
body {width:95%;max-width:700px;margin:5px auto;background:#EFEFF4;cursor:default} | |
.comic-cover{height:200px} | |
.episode.block{display:inline-block;width:50px;margin:8px 15px;border:1px solid;border-radius:5px;text-align:center;} | |
.episode.locked{color:#666;border-color:#666} | |
a.episode:visited{color:#0645ad} | |
rt{font-size:70%;color:#888} | |
.multiple-version{position:relative} | |
.multiple-version::after{content:"*";position:absolute} | |
.contents-full{text-align:left} | |
.flex{display:flex} | |
.flex.hoz{flex-direction:horizontal} | |
.flex.ver{flex-direction:vertical} | |
.flex>div{flex:1} | |
.flex>.epid{flex:1.5} | |
.comments::before{content: "";background:url('data:image/svg+xml,%3Csvg height%3D"20" width%3D"25" viewBox%3D"0 0 60 60" version%3D"1.1" xmlns%3D"http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg"%3E%3Cpath d%3D"M12%2C17h15c0.553%2C0%2C1-0.448%2C1-1s-0.447-1-1-1H12c-0.553%2C0-1%2C0.448-1%2C1S11.447%2C17%2C12%2C17z"%2F%3E%3Cpath d%3D"M46%2C23H12c-0.553%2C0-1%2C0.448-1%2C1s0.447%2C1%2C1%2C1h34c0.553%2C0%2C1-0.448%2C1-1S46.553%2C23%2C46%2C23z"%2F%3E%3Cpath d%3D"M46%2C31H12c-0.553%2C0-1%2C0.448-1%2C1s0.447%2C1%2C1%2C1h34c0.553%2C0%2C1-0.448%2C1-1S46.553%2C31%2C46%2C31z"%2F%3E%3Cpath d%3D"M54%2C2H6C2.748%2C2%2C0%2C4.748%2C0%2C8v33c0%2C3.252%2C2.748%2C6%2C6%2C6h8v10c0%2C0.413%2C0.254%2C0.784%2C0.64%2C0.933C14.757%2C57.978%2C14.879%2C58%2C15%2C58c0.276%2C0%2C0.547-0.115%2C0.74-0.327L25.442%2C47H54c3.252%2C0%2C6-2.748%2C6-6V8C60%2C4.748%2C57.252%2C2%2C54%2C2z M58%2C41c0%2C2.168-1.832%2C4-4%2C4H27.179l3.579-4.161c0.36-0.418%2C0.313-1.05-0.105-1.41c-0.419-0.358-1.05-0.312-1.41%2C0.106l-4.982%2C5.792l0%2C0L16%2C54.414V46c0-0.552-0.447-1-1-1H6c-2.168%2C0-4-1.832-4-4V8c0-2.168%2C1.832-4%2C4-4h48c2.168%2C0%2C4%2C1.832%2C4%2C4V41z"%2F%3E%3C%2Fsvg%3E') 0 0/25px 20px no-repeat;width:25px;height:25px;display:inline-block;opacity:.6;vertical-align:middle;position:relative;top:3px} | |
.contents-full{display:none} | |
body.show-full-info .contents-simple{display:none} | |
body.show-full-info .contents-full{display:block} | |
.removal_banner{border:red solid 2px;padding:6px 20px 8px;border-radius:10px;background:#ff5151;color:white} | |
@media (prefers-color-scheme: dark) { | |
body {background:black;color:#DDD} | |
img {filter:brightness(0.8)} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="col s12"> | |
<h3><?php echo $detail['title'];?></h3> | |
<div style="float:right"><?php echo implode('・', $detail['styles']);?></div> | |
<h5><?php echo implode(' ', $detail['author_name']);?></h5> | |
<?php | |
if ($detail['status'] !== 0) { | |
?> | |
<h6 class="removal_banner">该作品已下架</h6> | |
<?php | |
} | |
?> | |
<div style="float:right"><?php if (!empty($detail['square_cover'])) {?><img style="height:0;width:0" alt="封面" src="<?php echo wrapPicUrl(str_replace('http:','',$detail['square_cover']).'@100h.jpg');?>"><?php } ?><img style="height:200px" alt="封面" src="<?php echo wrapPicUrl(str_replace('http:','',$detail['vertical_cover']).'@400h.jpg');?>"><div style="text-align:center"><?php | |
$covers = []; | |
foreach (['horizontal_cover'=>'横封','vertical_cover'=>'竖封','square_cover'=>'方封'] as $key=>$name) { | |
if (!empty($detail[$key])) $covers[] = '<a href="'.wrapPicUrl(str_replace('http:','',$detail[$key])).'" target="_blank">'.$name.'</a>'; | |
} | |
echo implode(' ', $covers); | |
?></div></div> | |
<p style="white-space:pre-wrap"><?php echo $detail['evaluate'];?></p> | |
<p>上次阅读:<?php echo $detail['read_short_title'];?></p> | |
<p><?php echo $detail['is_finish'] ? '已完结' :$detail['renewal_time'];?></p> | |
<?php if (!empty($discount_desc)) { | |
?> | |
<p>进行中的优惠:<?php echo implode(' & ', $discount_desc) ?> | <?php echo $detail['discount_end']?></p> | |
<?php | |
}?> | |
<?php if (!empty($detail['disable_coupon_amount'])) { | |
?> | |
<p>最新 <?php echo $detail['disable_coupon_amount'] ?> 章不可使用福利券</p> | |
<?php | |
}?> | |
<?php if (!empty($detail['wait_hour'])) { | |
?> | |
<p>包含等免章节,<?php echo $detail['wait_hour']?>小时一章节,<?php echo $detail['wait_free_at'] > date('Y-m-d H:i:s') ? $detail['wait_free_at'].' 可租赁下一章节' : '当前可租赁'?></p> | |
<?php | |
}?> | |
<p><a href="/html/reply.htm#type=22&id=<?php echo $_GET['mangaid']?>&title=<?php echo rawurlencode($detail['title'])?>" target="_blank">评论区</a><span class="review"></span> | <a href="?act=batch_index&mangaid=<?php echo $_GET['mangaid']?>" target="_blank">刷新全部索引</a> | <a href="?act=batch_index&skip_cached&mangaid=<?php echo $_GET['mangaid']?>" target="_blank">获取未缓存索引</a> | <a href="?act=detail_preview&mangaid=<?php echo $_GET['mangaid']?>" target="_manga_view">章节预览</a></p> | |
<?php if (!empty($detail['album_count'])) { | |
?> | |
<p><a href="?act=album&mangaid=<?php echo $detail['id'];?>">特典解锁</a>(<?php echo $detail['album_count'] ?>章)</p> | |
<?php | |
}?> | |
<p> | |
<label style="color:inherit"><input type="checkbox" id="showFullChapterInfo" style="opacity:initial;position:initial;pointer-events:initial">显示完整目录</label> | |
<label style="color:inherit"><input type="checkbox" id="revertSort" autocomplete="off" style="opacity:initial;position:initial;pointer-events:initial">逆序目录</label> | |
<?php require 'sw_script.php'; ?> | |
</p> | |
<p>© Copyright <a href="https://manga.bilibili.com/m/detail/mc<?php echo $_GET['mangaid']?>" target="_blank">bilibili</a></p> | |
<div style="clear:both"></div> | |
<?php | |
if (!empty($detail['series_info']['comics'])) { | |
?> | |
<ul class="browser-default"> | |
<?php | |
foreach ($detail['series_info']['comics'] as $series_comic) { | |
?> | |
<li><?php echo $series_comic['comic_id'] == $detail['id'] ? $series_comic['title'] : '<a href="?act=detail&mangaid='.$series_comic['comic_id'].'">'.$series_comic['title'].'</a>'?></li> | |
<?php | |
} | |
?> | |
</ul> | |
<?php | |
} | |
?> | |
<div style="text-align:center" id="contents"><!-- | |
<?php | |
$idxCntStmt = $idxDb->prepare('SELECT count(hash) FROM index_data_history WHERE epid=?'); | |
foreach ($detail['ep_list'] as $ep) { | |
$idxCntStmt->execute([$ep['id']]); | |
$idxCnt = $idxCntStmt->fetch()[0]; | |
$isRent = $ep['unlock_expire_at'] != '0000-00-00 00:00:00'; | |
?> | |
|--><span><!-- | |
|--><ruby class="contents-simple"><a class="episode block<?php if ($ep['is_locked'] && !$ep['is_in_free']) echo ' locked'; if ($idxCnt>1) echo ' multiple-version'?>" target="_manga_view" href="?act=read&mangaid=<?php echo $detail['id'];?>&epid=<?php echo $ep['id'];?>"><?php echo $ep['short_title'];?></a><rt><?php echo $ep['id'];?></rt></ruby><!-- | |
|--><div class="contents-full"><hr><a class="episode<?php if ($ep['is_locked'] && !$ep['is_in_free']) echo ' locked'; if ($idxCnt>1) echo ' multiple-version'?>" target="_manga_view" href="?act=read&mangaid=<?php echo $detail['id'];?>&epid=<?php echo $ep['id'];?>"><?php echo $ep['short_title'].'. '.$ep['title'];?></a><br><div class="flex hoz"><div class="epid">ID: <?php echo $ep['id'];?></div><div class="comments"><?php echo $ep['comments']?></div><div><?php echo ($ep['pay_gold'] ? $ep['is_locked'] || $isRent ? $ep['pay_gold'].' 漫币' . ($ep['allow_wait_free'] ? '/免费' : '') : '已购买' : '免费')?></div><div><?php echo $ep['read']?'已':'未'?>读过</div><div><?php echo implode(' / ', array_filter([prettySize($ep['size']), $ep['image_count'] . '页']))?></div></div><?php echo $ep['pub_time']?> 发布<?php if ($isRent) echo ' | 租赁至 '.$ep['unlock_expire_at'] ?></div><!-- | |
|--></span><!-- | |
<?php | |
} | |
?> | |
|--></div> | |
</div> | |
<script> | |
function review_count(data){if(data.code==0){[].slice.call(document.getElementsByClassName('review')).forEach(function(i){i.textContent='('+data.data.count+')';})}} | |
showFullChapterInfo.checked = localStorage.showFullChapterInfo == 1; | |
document.body.classList[showFullChapterInfo.checked?'add':'remove']('show-full-info'); | |
showFullChapterInfo.addEventListener('change', function () { | |
localStorage.showFullChapterInfo = this.checked ? 1 : 0; | |
document.body.classList[this.checked?'add':'remove']('show-full-info'); | |
}) | |
revertSort.checked = localStorage.revertSort == 1; | |
revertSort.addEventListener('change', function () { | |
localStorage.revertSort = this.checked ? 1 : 0; | |
for (var i = contents.children.length - 1; i>=0; i--) { | |
contents.appendChild(contents.children[i]); | |
} | |
}) | |
if (revertSort.checked) setTimeout(function () {revertSort.dispatchEvent(new Event('change'))}, 0); | |
if (Date.now() - parseInt(localStorage.oauthTime) > 24 * 60 * 60 * 1000 || localStorage.oauthTime == undefined) { | |
var s = document.createElement('script'); | |
s.src = '/login?act=expiretime&days=10'; | |
document.body.appendChild(s); | |
s.remove(); | |
} | |
</script> | |
<script src="https://www.biliplus.com/api/reply?isCount=1&oid=<?php echo $_GET['mangaid'];?>&type=22&jsonp=jsonp&callback=review_count&_=<?php echo time();?>" async></script> | |
</body> | |
</html> |
<?php | |
if (empty($_GET['mangaid']) || !preg_match('(^\d+$)', $_GET['mangaid'])) { | |
errordie('无效id'); | |
} | |
chdir(__DIR__); | |
$idxDb = new PDO('sqlite:index.db'); | |
$mangaInfoStmt = $idxDb->prepare('SELECT * FROM manga_info WHERE id=?'); | |
$mangaInfoStmt->execute([$_GET['mangaid']]); | |
$mangaInfo = $mangaInfoStmt->fetch(PDO::FETCH_ASSOC); | |
if (empty($mangaInfo)) { | |
header('Location: ?act=detail&mangaid='.$_GET['mangaid'], true, 302); | |
exit; | |
} | |
$mangaTitle = $mangaInfo['title']; | |
$epList = json_decode(brotli_uncompress($mangaInfo['list']), true); | |
usort($epList, function ($a, $b) {return $a['ord'] > $b['ord'] ? 1 : -1;}); | |
$epLen = count($epList); | |
$usePaging = $epLen > 300; | |
if ($usePaging) { | |
$page = 1; | |
if (!empty($_GET['page'])) $page = ($_GET['page'] | 0) ?: 1; | |
$offset = ($page - 1) * 200; | |
if ($offset > $epLen) $offset = 0; | |
$len = 200; | |
if ($epLen - $offset <= 250) { | |
$len = $epLen - $offset; | |
} | |
$epList = array_slice($epList, $offset, $len); | |
$epPages = ceil($epLen / 200) | 0; | |
if (($epLen % 200) <= 50) $epPages--; | |
} | |
$epIdxStmt = $idxDb->prepare('SELECT b.data FROM index_data AS a,index_data_history AS b WHERE a.epid=? AND a.epid=b.epid AND a.hash=b.hash'); | |
$imgUrl = []; | |
foreach ($epList as &$ep) { | |
$epIdxStmt->execute([$ep['id']]); | |
$epIdx = $epIdxStmt->fetch(); | |
if (empty($epIdx)) continue; | |
$epIdx = json_decode(brotli_uncompress($epIdx['data']), true); | |
$idx = 0; | |
if (in_array($epIdx['pics'][$idx], ['/bfs/manga/dc7914d65771003337d24be6281c6189934b89c0.jpg', '/bfs/manga/e06419ed685fab1df07134fc4e0f2e9011808cb5.jpg'])) $idx++; | |
$img = getImgUrl($epIdx, $idx, 150); | |
$img['path'] = preg_replace('(.*//[^/]+(.+))', '$1', $img['url']); | |
$ep['first_image'] = $img; | |
$imgUrl[] = $img['url']; | |
} | |
unset($ep); | |
function getImgUrl(&$indexData, $i, $setWidth) { | |
$append = '@'.($setWidth*2).'w.jpg'; | |
if ($indexData['sizes'][$i]['cx'] <= $setWidth * 2) { | |
$append = '@.jpg'; | |
} | |
return [ | |
'url' => 'https://'.IMG_HOST.$indexData['pics'][$i].$append, | |
'size' => [ | |
$setWidth, | |
$setWidth / $indexData['sizes'][$i]['cx'] * $indexData['sizes'][$i]['cy'] | |
] | |
]; | |
} | |
$tokens = empty($imgUrl) ? ['code'=>0,'data'=>[]]: json_decode(cget('http://manga.bilibili.com/twirp/comic.v1.Comic/ImageToken', [ | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'urls'=>json_encode($imgUrl), | |
'version'=>APPVER, | |
], $appsecret) | |
]), true); | |
if (!$tokens || $tokens['code'] != 0) { | |
errordie('获取凭据出错: ['.$tokens['code'].']'.$tokens['msg']); | |
} | |
$tokenMap = []; | |
foreach ($tokens['data'] as $item) { | |
$tokenMap[preg_replace('(.*//[^/]+(.+))', '$1', $item['url'])] = $item; | |
} | |
?> | |
<!DOCTYPE html><html> | |
<head> | |
<meta charset="UTF-8" name="viewport" content="width=device-width"> | |
<meta name="format-detection" content="telephone=no" /> | |
<meta name="referrer" content="never"> | |
<title>章节预览 - <?php echo $mangaInfo['title'];?> - 书籍 - bilibili漫画 阅读器 - BiliPlus</title> | |
<script src="materialize.min.js"></script> | |
<link rel="stylesheet" href="materialize.min.css" /> | |
<style> | |
body {width:95%;max-width:700px;margin:5px auto;background:#EFEFF4;cursor:default} | |
.episode-item{margin:5px 3px;display:inline-block} | |
.episode-item img{width:150px} | |
rt{font-size:100%;color:#888} | |
.ep-title{width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
@media (prefers-color-scheme: dark) { | |
body {background:black;color:#DDD} | |
img {filter:brightness(0.8)} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="col s12"> | |
<h3><?php echo $mangaInfo['title'];?></h3> | |
<p>© Copyright <a href="https://manga.bilibili.com/m/detail/mc<?php echo $_GET['mangaid']?>" target="_blank">bilibili</a></p> | |
<div style="clear:both"></div> | |
<?php | |
function printPaging() { | |
global $usePaging, $offset, $epPages, $epLen, $epList; | |
if ($usePaging) { | |
$page = $offset / 200 + 1; | |
$get = $_GET; | |
?> | |
<hr> | |
<center><p><?php printf("%s-%s/%s", $offset + 1, $offset + count($epList), $epLen);?></p></center> | |
<center><ul class="pagination"> | |
<li class="<?php echo $page===1 ? 'active' : 'waves-effect'?>"><a href="?<?php $get['page']=1;echo http_build_query($get);?>">1</a></li> | |
<?php | |
if ($page > 4) { | |
?> | |
<li class="disabled">…</li> | |
<?php } | |
for ($i = 0; $i<5; $i++) { | |
$p = $page - 2 + $i; | |
if ($p > $epPages - 1) break; | |
if ($p < 2) continue; | |
?> | |
<li class="<?php echo $p===$page ? 'active' : 'waves-effect'?>"><a href="?<?php $get['page']=$p;echo http_build_query($get);?>"><?php echo $p?></a></li> | |
<?php } | |
if ($page < $epPages - 3) { | |
?> | |
<li class="disabled">…</li> | |
<?php } ?> | |
<li class="<?php echo $page===$epPages ? 'active' : 'waves-effect'?>"><a href="?<?php $get['page']=$epPages;echo http_build_query($get);?>"><?php echo $epPages?></a></li> | |
</ul></center> | |
<?php | |
} | |
} | |
printPaging(); | |
?> | |
<div style="text-align:center"><!-- | |
<?php | |
foreach ($epList as $ep) { | |
if (isset($ep['first_image'])) { | |
$imgPath = $ep['first_image']['path']; | |
$attr = 'src="'.wrapPicUrl($tokenMap[$imgPath]['url'].'?token='.$tokenMap[$imgPath]['token']).'" width="'.$ep['first_image']['size'][0].'" height="'.$ep['first_image']['size'][1].'"'; | |
} else { | |
$attr = 'src="about:blank"'; | |
} | |
?> | |
|--><div class="episode-item"><a href="?act=read&mangaid=<?php echo $mangaInfo['id'];?>&epid=<?php echo $ep['id'];?>"><img <?php echo $attr?>></a><br><?php echo $ep['short_title'].' ('.$ep['id'];?>)<?php if (!empty($ep['title'])) echo '<br><div class="ep-title">'.$ep['title'].'</div>'?></div><!-- | |
<?php | |
} | |
?> | |
|--></div> | |
<?php printPaging() ?> | |
</div> | |
</body> | |
</html> |
<?php | |
if (empty($_GET['epid']) || !preg_match('(^\d+$)', $_GET['epid'])) { | |
errordie('无效id'); | |
} | |
chdir(__DIR__); | |
$idxDb = new PDO('sqlite:index.db'); | |
$result = $idxDb->prepare('SELECT * FROM index_data_history WHERE epid=?'); | |
$result->execute([$_GET['epid']]); | |
$resultArr = array_map(function ($i) {$i['data'] = json_decode(brotli_uncompress($i['data']), true);return $i;}, $result->fetchAll(PDO::FETCH_ASSOC)); | |
usort($resultArr, function ($a, $b) {return $a['time'] - $b['time'];}); | |
$diff = []; | |
$imgInfo = []; | |
for ($i=0; $i<count($resultArr) - 1; $i++) { | |
$subdiff = []; | |
$len = max(count($resultArr[$i]['data']['pics']), count($resultArr[$i+1]['data']['pics'])); | |
for ($j=0; $j<$len; $j++) { | |
if ($resultArr[$i]['data']['pics'][$j] != $resultArr[$i+1]['data']['pics'][$j]) { | |
$imgInfo[ $resultArr[$i]['data']['pics'][$j] ] = $resultArr[$i]['data']['sizes'][$j]; | |
$imgInfo[ $resultArr[$i+1]['data']['pics'][$j] ] = $resultArr[$i+1]['data']['sizes'][$j]; | |
$subdiff[] = [ | |
$j + 1, | |
$resultArr[$i]['data']['pics'][$j], | |
$resultArr[$i+1]['data']['pics'][$j] | |
]; | |
} | |
} | |
$diff[] = [ | |
'hash' => [dechex($resultArr[$i]['hash']), dechex($resultArr[$i+1]['hash'])], | |
'time' => [$resultArr[$i]['time'], $resultArr[$i+1]['time']], | |
'diff' => $subdiff | |
]; | |
} | |
unset($imgInfo['']); | |
$setWidth = 400; | |
foreach (array_keys($imgInfo) as $img) { | |
$size = $imgInfo[$img]; | |
$append = '@'.($setWidth*2).'w.jpg'; | |
if ($size['cx'] <= $setWidth * 2) { | |
$append = '@.jpg'; | |
} | |
$url = 'https://'.IMG_HOST.$img.$append; | |
$imgInfo[$img] = [ | |
'cx' => $setWidth, | |
'cy' => $setWidth / $size['cx'] * $size['cy'], | |
'url' => $url | |
]; | |
} | |
$imgUrl = array_values(array_map(function ($i){return $i['url'];}, $imgInfo)); | |
$tokens = empty($imgUrl) ? ['code'=>0,'data'=>[]]: json_decode(preg_replace('/https?:\/\/i(0|1|2|s)\.hdslb\.com\//', 'https://'.IMG_HOST.'/', cget('http://manga.bilibili.com/twirp/comic.v1.Comic/ImageToken', [ | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'urls'=>json_encode($imgUrl), | |
'version'=>APPVER, | |
], $appsecret) | |
])), true); | |
if (!$tokens || $tokens['code'] != 0) { | |
errordie('获取凭据出错: ['.$tokens['code'].']'.$tokens['msg']); | |
} | |
$tokenMap = []; | |
foreach ($tokens['data'] as $item) { | |
$tokenMap[$item['url']] = $item['token']; | |
} | |
?> | |
<!DOCTYPE html><html> | |
<head> | |
<meta charset="UTF-8" name="viewport" content="width=device-width"> | |
<meta name="format-detection" content="telephone=no" /> | |
<meta name="referrer" content="never"> | |
<title>章节索引历史 - bilibili漫画 阅读器 - BiliPlus</title> | |
<script src="materialize.min.js"></script> | |
<link rel="stylesheet" href="materialize.min.css" /> | |
<style> | |
body {width:800px;margin:5px auto;background:#EFEFF4;cursor:default;text-align:center} | |
span.col {display:inline-block} | |
.row img.col{padding:0} | |
@media (prefers-color-scheme: dark) { | |
body {background:black;color:#DDD} | |
img {filter:brightness(0.8)} | |
} | |
.invert-diff p+img.col.s6 { | |
filter: none; | |
position: relative; | |
left: 25% | |
} | |
.invert-diff p+img+img.col.s6 { | |
position: relative; | |
left: -25%; | |
opacity: 0.5; | |
filter: invert(100%); | |
} | |
</style> | |
</head> | |
<body> | |
<div class="row s12"> | |
<?php | |
foreach ($diff as $subdiff) { | |
?> | |
<hr> | |
<span class="col s6"><?php echo date('Y/m/d H:i:s', $subdiff['time'][0])?><br><?php echo $subdiff['hash'][0]?></span> | |
<span class="col s6"><?php echo date('Y/m/d H:i:s', $subdiff['time'][1])?><br><?php echo $subdiff['hash'][1]?></span> | |
<?php | |
foreach ($subdiff['diff'] as $diffitem) { | |
?> | |
<p>P<?php echo $diffitem[0]?></p> | |
<img class="col s6" width="<?php echo $imgInfo[$diffitem[1]]['cx']?>" height="<?php echo $imgInfo[$diffitem[1]]['cy']?>" _src="<?php $url = $imgInfo[$diffitem[1]]['url']; echo $url.'?token='.$tokenMap[$url]?>"><!-- | |
|--><img class="col s6" width="<?php echo $imgInfo[$diffitem[2]]['cx']?>" height="<?php echo $imgInfo[$diffitem[2]]['cy']?>" _src="<?php $url = $imgInfo[$diffitem[2]]['url']; echo $url.'?token='.$tokenMap[$url]?>"> | |
<?php | |
} | |
} | |
?> | |
</div> | |
<div class="fixed-action-btn" id="invert_toggle"><div style="background:rgba(255,255,255,.6);text-align:center;font-family:Arial;color:#000;padding:2px 8px">切换<br>负片</div></div> | |
<script> | |
window.img_lazyload=function(){var t=[].slice.call(document.getElementsByTagName("img"));t.forEach(function(t){if(t.hasAttribute("_src")){var e=t.getBoundingClientRect();e.bottom<-100||e.top>innerHeight+1000||e.right<-1000||e.left>innerWidth+100||(t.setAttribute("src",t.getAttribute("_src")),t.removeAttribute("_src"))}})},window.addEventListener("scroll",img_lazyload),window.addEventListener("resize",img_lazyload),img_lazyload(); | |
invert_toggle.addEventListener('click', function () { | |
document.body.classList.toggle('invert-diff') | |
}) | |
</script> | |
</body> | |
</html> |
<?php | |
require_once '../include/gzip.php'; | |
require_once $root_prefix.'include/functions.php'; | |
$appkey = 'da44a5d9227fa9ef'; | |
$appsecret = 'ad875eed760f65ac5ade5f363ab05e42'; | |
define('APPVER', '3.7.3'); | |
define('APPBUILD', '873'); | |
define('IMG_HOST', 'manga.hdslb.com'); | |
define('USE_BILI_STATIC', false); | |
header('NO-CONVERT-IMG: 1'); | |
header('Content-Type: text/html'); | |
define('OVERSEA_MANGA_IDS', []); | |
if (isset($_GET['set_buy_platform'])) { | |
if (in_array($_GET['set_buy_platform'], ['ios', 'android'])) { | |
setcookie('manga-buy-platform', $_GET['set_buy_platform'], 0x7fffffff, '/manga/', $domain, true, true); | |
} | |
$uri = '/manga/'; | |
unset($_GET['set_buy_platform']); | |
if (!empty($_GET)) { | |
$uri .= '?'.http_build_query($_GET); | |
} | |
header('Location: '.$uri, true, 302); | |
exit; | |
} | |
$platform = 'ios'; | |
if (!empty($_COOKIE['manga-buy-platform']) && $_COOKIE['manga-buy-platform'] == 'android') $platform = 'android'; | |
define('PLATFORM', $platform); | |
function errordie($reason, $extraHead = '') { | |
?> | |
<!DOCTYPE html><html> | |
<head> | |
<meta charset="UTF-8" name="viewport" content="width=device-width,user-scalable=no"> | |
<meta name="format-detection" content="telephone=no" /> | |
<?php echo $extraHead?> | |
<title>bilibili漫画 阅读器 - BiliPlus</title> | |
<style> | |
body {width:95%;max-width:600px;margin:5px auto !important;background:#EFEFF4;cursor:default} | |
@media (prefers-color-scheme: dark) { | |
body {background:black;color:#DDD} | |
img {filter:brightness(0.8)} | |
} | |
</style> | |
</head> | |
<body> | |
<p><?php echo $reason;?></p> | |
</body> | |
</html><?php | |
exit; | |
} | |
function wrapPicUrl(string $url) { | |
if (!USE_BILI_STATIC) return $url; | |
if (!preg_match('/^.*\/\/([^\.]+\.hdslb\.com.*)$/', $url, $match)) return $url; | |
return 'https://bili-static.acgvideo.com/'.urlencode($match[1]); | |
} | |
if ($_COOKIE['login'] != 2) { | |
errordie('未登录<br><a href="javascript:localStorage.enablePlayback=\'on\',location.href=\'/login\'">登录</a>'); | |
} | |
if (isset($_GET['act'])) { | |
switch ($_GET['act']) { | |
case 'detail': { | |
require_once 'detail.php'; | |
exit; | |
} | |
case 'batch_index': { | |
require_once 'batch_index.php'; | |
exit; | |
} | |
case 'detail_preview': { | |
require_once 'detail_preview.php'; | |
exit; | |
} | |
case 'read': { | |
require_once 'read.php'; | |
exit; | |
} | |
case 'diff': { | |
require_once 'diff.php'; | |
exit; | |
} | |
case 'album': { | |
require_once 'album.php'; | |
exit; | |
} | |
case 'read_album': { | |
require_once 'read_album.php'; | |
exit; | |
} | |
} | |
} | |
require_once 'listfav.php'; |
<?php | |
$page = 1; | |
if (!empty($_GET['page']) && preg_match('/^\d+$/', $_GET['page'])) { | |
$page = $_GET['page']; | |
} | |
if (isset($_GET['order'])) { | |
if (in_array($_GET['order'], ['add','last_update','last_read'])) { | |
setcookie('manga-order', $_GET['order'], 0x7fffffff, '/manga/', $domain, true, true); | |
} | |
$uri = '/manga/'; | |
unset($_GET['order']); | |
if (!empty($_GET)) { | |
$uri .= '?'.http_build_query($_GET); | |
} | |
header('Location: '.$uri, true, 302); | |
exit; | |
} | |
$pagesize = 36; | |
$order = 2; | |
if (!empty($_COOKIE['manga-order'])) { | |
switch ($_COOKIE['manga-order']) { | |
case 'add': { | |
$order = 1; | |
break; | |
} | |
case 'last_read': { | |
$order = 3; | |
} | |
} | |
} | |
$curl = curl_init(); | |
if (!empty($_COOKIE['vipDueDate']) && (empty($_COOKIE['vip-monthly-coupons']) || $_COOKIE['vip-monthly-coupons'] < time())) { | |
$getVipReward = json_decode(cget('https://manga.bilibili.com/twirp/user.v1.User/GetVipReward', [ | |
'curl' => $curl, | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'order'=>$order, | |
'page_num'=>$page, | |
'page_size'=>$pagesize, | |
'platform'=>'ios', | |
'ts'=>time(), | |
'version'=>APPVER, | |
'reason_id'=>1, | |
], $appsecret) | |
]), true); | |
if (!empty($getVipReward)) { | |
if ($getVipReward['code'] == 1 || $getVipReward['code'] === 0) { | |
// start from 1646064000 Mar 01 2022 00:00:00 GMT+0800 | |
$nextReward = ceil((time()-1646064000)/2678400)*2678400+1646064000; | |
setcookie('vip-monthly-coupons', $nextReward, 0x7fffffff, '/manga/', $domain, true, true); | |
} | |
} | |
} | |
$favList = cget('http://manga.bilibili.com/twirp/bookshelf.v1.Bookshelf/ListFavorite', [ | |
'curl' => $curl, | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'order'=>$order, | |
'page_num'=>$page, | |
'page_size'=>$pagesize, | |
'platform'=>'ios', | |
'ts'=>time(), | |
'version'=>APPVER, | |
], $appsecret) | |
]); | |
if (isset($_GET['api'])) { | |
header('Content-Type: application/json; charset="UTF-8"'); | |
echo $favList; exit; | |
} | |
$favList = json_decode($favList, true); | |
if ($order == 2) usort($favList['data'], function ($a, $b) {return $a['last_ep_publish_time']>$b['last_ep_publish_time']?-1:1;}); | |
$hasPrevPage = $page > 1; | |
$hasNextPage = count($favList['data']) >= $pagesize; | |
$hasNextPage = true; // 列表中某页下架漫画会导致该页不足pageSize个……哪个弱智写的先select再filter的?? | |
?> | |
<!DOCTYPE html><html> | |
<head> | |
<meta charset="UTF-8" name="viewport" content="width=device-width,user-scalable=no"> | |
<meta name="format-detection" content="telephone=no" /> | |
<meta name="referrer" content="never"> | |
<title>书架 - bilibili漫画 阅读器 - BiliPlus</title> | |
<script src="materialize.min.js" defer></script> | |
<script> | |
document.addEventListener('DOMContentLoaded', function () { | |
var elems = document.querySelectorAll('select'); | |
var instances = M.FormSelect.init(elems, {}); | |
if (Date.now() - parseInt(localStorage.oauthTime) > 24 * 60 * 60 * 1000 || localStorage.oauthTime == undefined) { | |
var s = document.createElement('script'); | |
s.src = '/login?act=expiretime&days=10'; | |
document.body.appendChild(s); | |
s.remove(); | |
} | |
}); | |
</script> | |
<link rel="stylesheet" href="materialize.min.css" /> | |
<style> | |
body {width:95%;max-width:600px;margin:5px auto;background:#EFEFF4;cursor:default} | |
.comic-cover{height:200px} | |
.disabled{pointer-events:none} | |
@media (prefers-color-scheme: dark) { | |
body {background:black;color:#DDD} | |
.card,.dropdown-content {background:#1c1c1c} | |
.select-wrapper input.select-dropdown {color:white} | |
.select-wrapper .caret {fill:rgba(255,255,255,0.87)} | |
.pagination li a {color:white} | |
img {filter:brightness(0.8)} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="col s12"> | |
<h2>书架</h2> | |
<p class="row"> | |
<span class="col s6">© Copyright <a href="https://manga.bilibili.com/m/" target="_blank">bilibili</a></span> | |
<span class="input-field col s4"> | |
<select autocomplete="off" onchange="location.href=location.href+(location.href.indexOf('?')==-1?'?':'&')+'order='+this.value"> | |
<option <?php if ($order == 1) echo 'selected ' ?>value="add">追漫</option> | |
<option <?php if ($order == 2) echo 'selected ' ?>value="last_update">更新</option> | |
<option <?php if ($order == 3) echo 'selected ' ?>value="last_read">阅读</option> | |
</select> | |
<label>排序:</label> | |
</span> | |
</p> | |
<div> | |
<?php require 'sw_script.php'; ?> | |
</div> | |
<?php | |
//$userInit = ['data'=>['recieved_coupons'=>[['id'=>580482,'amount'=>10,'expire_time'=>'2019-12-10 10:36:05','reason'=>'大会员特权','type'=>'全场券','ctime'=>'']]]]; | |
// recieved??? | |
if (!empty($getVipReward) && $getVipReward['code'] === 0) { | |
?> | |
<div class="card horizontal"> | |
<div class="card-stacked"> | |
<div class="card-content"> | |
<p>已领取本月 大会员漫读券 x <?php echo $getVipReward['data']['amount'];?></p> | |
<p><?php echo htmlspecialchars(json_encode($getVipReward, JSON_UNESCAPED_UNICODE+JSON_UNESCAPED_SLASHES));?></p> | |
</div> | |
</div> | |
</div> | |
<hr> | |
<?php | |
} | |
if ($hasPrevPage || $hasNextPage) { | |
?> | |
<ul class="pagination row s12"> | |
<li class="col s2 waves-effect<?php if (!$hasPrevPage) echo ' disabled'; ?>"><a href="?page=<?php echo $page-1;?>"><</a></li> | |
<li class="col s8"></li> | |
<li class="col s2 waves-effect<?php if (!$hasNextPage) echo ' disabled'; ?>"><a href="?page=<?php echo $page+1;?>">></a></li> | |
</ul> | |
<?php | |
} | |
foreach ($favList['data'] as $item) { | |
?> | |
<div class="card horizontal"> | |
<div class="card-image"> | |
<img class="comic-cover" alt="封面" src="<?php echo wrapPicUrl(str_replace('http:','',$item['vcover']).'@400h.jpg');?>"> | |
</div> | |
<div class="card-stacked"> | |
<div class="card-content"> | |
<p><?php echo $item['title'];?></p> | |
<p>最近更新:[<?php echo $item['latest_ep_short_title'];?>] <?php echo $item['last_ep_publish_time'];?></p> | |
<p>上次阅读:<?php echo $item['last_ep_short_title'];?></p> | |
</div> | |
<div class="card-action"> | |
<a href="?act=detail&mangaid=<?php echo $item['comic_id'];?>">查看目录</a> | |
</div> | |
</div> | |
</div> | |
<?php | |
} | |
if (empty($favList['data'])) { | |
?> | |
<p style="text-align:center">书架里没有漫画了</p> | |
<?php | |
} | |
if (count ($favList['data']) > 3 && ($hasPrevPage || $hasNextPage)) { | |
?> | |
<ul class="pagination row s12"> | |
<li class="col s2 waves-effect<?php if (!$hasPrevPage) echo ' disabled'; ?>"><a href="?page=<?php echo $page-1;?>"><</a></li> | |
<li class="col s8"></li> | |
<li class="col s2 waves-effect<?php if (!$hasNextPage) echo ' disabled'; ?>"><a href="?page=<?php echo $page+1;?>">></a></li> | |
</ul> | |
<?php | |
} | |
?> | |
</div> | |
</body> | |
</html> |
<?php | |
if (empty($_GET['mangaid']) || !preg_match('(^\d+$)', $_GET['mangaid']) || | |
empty($_GET['epid']) || !preg_match('(^\d+$)', $_GET['epid'])) { | |
errordie('无效id'); | |
} | |
if (isset($_GET['add_history'])) { | |
echo cget('http://manga.bilibili.com/twirp/bookshelf.v1.Bookshelf/AddHistory', [ | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'comic_id'=>$_GET['mangaid'], | |
'device'=>'phone', | |
'ep_id'=>$_GET['epid'], | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'version'=>APPVER, | |
], $appsecret) | |
]); | |
exit; | |
} | |
function prettySize(int $s) { | |
if ($s <= 0) return null; | |
$units = ['B','KB','MB','GB']; | |
$unit = 0; | |
if ($s < 1001) return $s.'B'; | |
while ($s > 1000) { | |
$unit++; | |
$s/=1024; | |
} | |
return number_format($s, 2, '.', '') . $units[$unit]; | |
} | |
chdir(__DIR__); | |
$idxDb = new PDO('sqlite:index.db'); | |
$reseturl = false; | |
$mangaInfoStmt = $idxDb->prepare('SELECT * FROM manga_info WHERE id=?'); | |
$mangaInfoStmt->execute([$_GET['mangaid']]); | |
$mangaInfo = $mangaInfoStmt->fetch(PDO::FETCH_ASSOC); | |
if (empty($mangaInfo)) { | |
header('Location: ?act=detail&mangaid='.$_GET['mangaid'], true, 302); | |
exit; | |
} | |
$mangaTitle = $mangaInfo['title']; | |
$epSize = 0; | |
$epTitle = 'ep_id '.$_GET['epid']; | |
$epPages = 0; | |
$chapLink = ['']; | |
$epList = json_decode(brotli_uncompress($mangaInfo['list']), true); | |
usort($epList, function ($a, $b) {return $a['ord'] > $b['ord'] ? 1 : -1;}); | |
$epArr = array_map(function ($i) {return $i['id'];}, $epList); | |
$epIdx = array_search($_GET['epid'], $epArr); | |
if ($epIdx !== false) { | |
$epSize = $epList[$epIdx]['size']; | |
$epTitle = $epList[$epIdx]['short_title']; | |
$epPages = $epList[$epIdx]['image_count']; | |
$epFullTitle = trim($epList[$epIdx]['title']); | |
if ($epIdx > 0) { | |
$chapLink[] = '<a href="?act=read&mangaid='.$mangaInfo['id'].'&epid='.$epList[$epIdx - 1]['id'].'">上一话:'.$epList[$epIdx - 1]['short_title'].'</a>('.implode(' / ', array_filter([prettySize($epList[$epIdx - 1]['size']), $epList[$epIdx - 1]['image_count'] . '页'])).')'; | |
} | |
if ($epIdx < count($epArr) - 1) { | |
$chapLink[] = '<a href="?act=read&mangaid='.$mangaInfo['id'].'&epid='.$epList[$epIdx + 1]['id'].'">下一话:'.$epList[$epIdx + 1]['short_title'].'</a>('.implode(' / ', array_filter([prettySize($epList[$epIdx + 1]['size']), $epList[$epIdx + 1]['image_count'] . '页'])).')'; | |
} | |
} | |
$epTitle = implode(' - ', [$epTitle, $mangaTitle]); | |
$oversea = ''; | |
if (in_array($_GET['mangaid'], OVERSEA_MANGA_IDS)) { | |
$oversea = 'us'; | |
} else if (strpos($mangaTitle, '(境外版)') !== false) { | |
$oversea = 'us'; | |
} | |
$stmt = $idxDb->prepare('SELECT b.data,a.hash FROM index_data AS a,index_data_history AS b WHERE a.epid=? AND a.epid=b.epid AND a.hash=b.hash'); | |
$curl = curl_init(); | |
$stmt->execute([$_GET['epid']]); | |
$row = $stmt->fetch(); | |
if (!empty($row) && !isset($_GET['refetch_index'])) { | |
$indexData = json_decode(brotli_uncompress($row['data']), true); | |
$hash = $row['hash']; | |
} else { | |
if (isset($_GET['buy']) && isset($_GET['payid'])) { | |
$reseturl = true; | |
$payParam = [ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'buy_method'=>$_GET['buy'], | |
'device'=>'phone', | |
'comic_id'=>$_GET['mangaid'], | |
'ep_id'=>$_GET['epid'], | |
'mobi_app'=>'iphone_comic', | |
'platform'=>PLATFORM, | |
'ts'=>time(), | |
'version'=>APPVER, | |
]; | |
$buy_endpoint = 'BuyEpisode'; | |
if ($_GET['buy'] == '2') { | |
// 漫读券购买 | |
$payParam['auto_pay_coupons_status'] = '2'; | |
$payParam['coupon_id'] = $_GET['payid']; | |
} else if ($_GET['buy'] == '3') { | |
// 漫币 | |
unset($payParam['comic_id']); | |
$payParam['auto_pay_gold_status'] = '2'; | |
$payParam['pay_amount'] = $_GET['payid']; | |
} else if ($_GET['buy'] == 'rent') { | |
// 限免卡 租赁 | |
unset($payParam['buy_method']); | |
$payParam['item_id'] = $_GET['payid']; | |
$buy_endpoint = 'RentEpisode'; | |
} else if ($_GET['buy'] == '4') { | |
// 等免 租赁 / 终端为购买 | |
} else if ($_GET['buy'] == '5') { | |
// 通用券 | |
unset($payParam['comic_id']); | |
$payParam['auto_pay_gold_status'] = '2'; | |
$payParam['pay_amount'] = $_GET['payid']; | |
} else { | |
errordie('购买参数无效'); | |
} | |
$payResult = json_decode(cget('http://manga.bilibili.com/twirp/comic.v1.Comic/'.$buy_endpoint, [ | |
'post' => buildParam($payParam, $appsecret), | |
'curl'=>$curl | |
]), true); | |
if ($payResult['code'] !== 0) { | |
errordie('购买出错: ['.$payResult['code'].']'.$payResult['msg']); | |
} | |
} | |
if (isset($_GET['refetch_index'])) $reseturl = true; | |
$idxUrl = json_decode(cget('http://manga.bilibili.com/twirp/comic.v1.Comic/GetImageIndex', [ | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'device'=>'phone', | |
'ep_id'=>$_GET['epid'], | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'version'=>APPVER, | |
], $appsecret), | |
'oversea' => $oversea, | |
'curl'=>$curl | |
]), true); | |
if ($idxUrl['code'] == 1 && $idxUrl['msg'] == 'need buy episode') { | |
$buyInfo = json_decode(cget('http://manga.bilibili.com/twirp/comic.v1.Comic/GetEpisodeBuyInfo', [ | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'device'=>'phone', | |
'ep_id'=>$_GET['epid'], | |
'mobi_app'=>'iphone_comic', | |
'platform'=>PLATFORM, | |
'ts'=>time(), | |
'version'=>APPVER, | |
], $appsecret), | |
'curl'=>$curl | |
]), true); | |
if ($buyInfo['code'] !== 0) { | |
errordie('获取购买信息出错: ['.$idxUrl['code'].']'.$idxUrl['msg']); | |
} | |
$buyInfo = $buyInfo['data']; | |
$hasRentItem = $buyInfo['remain_item']>0; | |
$canFreeRent = $buyInfo['allow_wait_free']; | |
$canFreeRentNow = $canFreeRent && $buyInfo['wait_free_at'] < date('Y-m-d H:i:s'); | |
errordie(implode('',[ | |
/* 标题 */'<h5>未购买章节</h5><h5>'.str_replace(['<','>'],['<','>'], $epTitle).'</h5>', | |
/* 大小 */'<p>'.implode(' / ', array_filter([prettySize($epSize), $epPages . '页'])).'</p>', | |
/* 钱包 */'<p>钱包:'.$buyInfo['remain_gold'].' 漫币 | '.$buyInfo['remain_coupon'].' 漫读券 | '.$buyInfo['remain_silver'].' 通用券 | '.$buyInfo['remain_item'].' 限免卡</p>', | |
/* 平台 */'<p>当前钱包平台:'.PLATFORM.' <a href="?'.$_SERVER['QUERY_STRING'].'&set_buy_platform='.['ios'=>'android','android'=>'ios'][PLATFORM].'" class="waves-effect waves-light btn">切换至'.['ios'=>'android','android'=>'ios'][PLATFORM].'</a></p>', | |
/* 跳转 */'<p><a href="/html/reply.htm#type=29&id='.$_GET['epid'].'&title='.rawurlencode($epTitle).'" target="_blank">评论区</a>'.implode(' | ', $chapLink).'</p>', | |
/* */'<p class="row s12"><a class="col s1"></a>', | |
/* 漫币 */'<a onclick="return confirmPay(this)" href="?'.$_SERVER['QUERY_STRING'].'&buy=3&payid='.$buyInfo['pay_gold'].'" class="col s4 waves-effect waves-light btn'.($buyInfo['remain_gold']<$buyInfo['pay_gold']?' disabled':'').'">'.$buyInfo['pay_gold'].' 漫币购买</a>', | |
/* */'<a class="col s2"></a>', | |
/* 漫读券 */'<a onclick="return confirmPay(this)" href="?'.$_SERVER['QUERY_STRING'].'&buy=2&payid='.$buyInfo['recommend_coupon_id'].'" class="col s4 waves-effect waves-light btn'.($buyInfo['remain_coupon']<1?' disabled':'').'">1 漫读券购买</a>', | |
/* */'<a class="col s1"></a></p>', | |
/* */'<p class="row s12"><a class="col s7"></a>', | |
/* 通用券 */'<a onclick="return confirmPay(this)" href="?'.$_SERVER['QUERY_STRING'].'&buy=5&payid='.$buyInfo['ep_silver'].'" class="col s4 waves-effect waves-light btn'.($buyInfo['remain_silver']<$buyInfo['ep_silver']?' disabled':'').'">'.$buyInfo['ep_silver'].' 通用券购买</a>', | |
/* */'<a class="col s1"></a></p>', | |
/* */'<p class="row s12"><a class="col s1"></a>', | |
/* 等免 */'<a href="?'.$_SERVER['QUERY_STRING'].'&buy=4&payid=0" class="col s4 waves-effect waves-light btn'.($canFreeRentNow?'':' disabled').'">'.($canFreeRentNow ? '免费租赁' : ($canFreeRent ? $buyInfo['wait_free_at'] . ' 免费' : '不可免费租赁')).'</a>', | |
/* */'<a class="col s2"></a>', | |
/* 限免卡 */'<a href="?'.$_SERVER['QUERY_STRING'].'&buy=rent&payid='.$buyInfo['recommend_item_id'].'" class="col s4 waves-effect waves-light btn'.($buyInfo['remain_item']<1?' disabled':'').'">1 限免卡租赁</a>', | |
/* */'<a class="col s1"></a></p>', | |
/* 预览 */'<p style="text-align:center"><img src="'.wrapPicUrl($buyInfo['first_image_url'].'?token='.$buyInfo['first_image_token']).'" width="500"></p>' | |
]), '<meta name="referrer" content="never" /><script src="materialize.min.js"></script><script>function confirmPay(e){return confirm("使用 "+e.textContent+"?")}</script><link rel="stylesheet" href="materialize.min.css" />'); | |
} | |
if ($idxUrl['code'] !== 0) { | |
errordie('获取索引出错: ['.$idxUrl['code'].']'.$idxUrl['msg']); | |
} | |
$idxCurl = curl_init(); | |
$encryptedIndexData = cget(str_replace('https:', 'http:', wrapPicUrl($idxUrl['data']['host'] . $idxUrl['data']['path'])), ['curl'=>$idxCurl, 'curlOPT'=>[CURLOPT_FILETIME=>true]]); | |
$remoteTime = curl_getinfo($idxCurl, CURLINFO_FILETIME); | |
$idxCode = curl_getinfo($idxCurl, CURLINFO_HTTP_CODE); | |
if ($idxCode != 200) { | |
errordie('下载索引出错 HTTP '.$idxCode); | |
} | |
preg_match('(/manga/(\d+)/(\d+)/data\.index)', $idxUrl['data']['path'], $idMatch); | |
if ($_GET['mangaid'] != $idMatch[1]) { | |
$reseturl = true; | |
$_GET['mangaid'] = $idMatch[1]; | |
} | |
$indexDataStr = decryptBiliComicIndex($encryptedIndexData, $idMatch[1], $idMatch[2]); | |
if (!$indexDataStr) { | |
errordie('下载索引出错'); | |
} | |
$indexData = @json_decode($indexDataStr, true); | |
if ($indexData == NULL) { | |
errordie('下载索引出错'); | |
} | |
$store_stmt = $idxDb->prepare('REPLACE INTO index_data (epid,mangaid,hash) VALUES (?,?,?)'); | |
$data = brotli_compress($indexDataStr, 9); | |
$hash = crc32($data); | |
$idxDb->beginTransaction(); | |
$store_stmt->execute([$_GET['epid'], $_GET['mangaid'], $hash]); | |
$idxDb->prepare('INSERT OR IGNORE INTO index_data_history (epid,mangaid,hash,data,time) VALUES (?,?,?,CAST(? AS BLOB),?)')->execute([$_GET['epid'], $_GET['mangaid'], $hash, $data, time()]); | |
$idxDb->prepare('REPLACE INTO index_path (epid,path) VALUES (?,?)')->execute([$_GET['epid'], explode('?', $idxUrl['data']['path'])[0]]); | |
$idxDb->commit(); | |
} | |
$urls = []; | |
$sizeResized = []; | |
$setWidth = 700; | |
$picFormat = 'jpg'; | |
$fullSizePic = false; | |
if (isset($_COOKIE['manga_pic_format'])) { | |
setcookie('manga_pic_format_http', $_COOKIE['manga_pic_format'], time() + 315360000, '/manga/', 'biliplus.com', true, true); | |
setcookie('manga_pic_format', 'delete', 1, '/manga/'); | |
$_COOKIE['manga_pic_format_http'] = $_COOKIE['manga_pic_format']; | |
} | |
if (isset($_COOKIE['manga_pic_format_http'])) { | |
switch ($_COOKIE['manga_pic_format_http']) { | |
case 'jpg-1400w': break; | |
case 'jpg-full': $fullSizePic = true; break; | |
case 'webp-1400w': $picFormat = 'webp'; break; | |
case 'webp-full': $picFormat = 'webp'; $fullSizePic = true; break; | |
} | |
} | |
for ($i=0; $i<count($indexData['pics']); $i++) { | |
$append = '@'.($setWidth*2)."w.$picFormat"; | |
if ($fullSizePic) { | |
$append = "@.$picFormat"; | |
if ($picFormat == 'jpg') { | |
$append = ''; | |
} | |
} else if ($indexData['sizes'][$i]['cx'] <= $setWidth * 2) { | |
$append = "@.$picFormat"; | |
} | |
$urls[] = 'https://'.IMG_HOST.$indexData['pics'][$i].$append; | |
$sizeResized[] = [ | |
$setWidth, | |
round($setWidth / $indexData['sizes'][$i]['cx'] * $indexData['sizes'][$i]['cy']) | |
]; | |
} | |
$tokens = json_decode(cget('http://manga.bilibili.com/twirp/comic.v1.Comic/ImageToken', [ | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'urls'=>json_encode($urls), | |
'version'=>APPVER, | |
], $appsecret), | |
'curl' => $curl | |
]), true); | |
if (!$tokens || $tokens['code'] != 0) { | |
errordie('获取凭据出错: ['.$tokens['code'].']'.$tokens['msg']); | |
} | |
$historyCnt = $idxDb->prepare('SELECT count(epid) FROM index_data_history WHERE epid=?'); | |
$historyCnt->execute([$_GET['epid']]); | |
$historyCnt = $historyCnt->fetch()[0]; | |
if ($historyCnt>1) $chapLink[] = '<a target="_blank" href="?act=diff&epid='.$_GET['epid'].'">索引历史比对</a>('.$historyCnt.')'; | |
$chapLink = implode(" | <!--\n |-->", $chapLink); | |
$idxTimeStmt = $idxDb->prepare('SELECT time FROM index_data_history WHERE epid=? AND hash=?'); | |
$idxTimeStmt->execute([$_GET['epid'], $hash]); | |
$idxTime = $idxTimeStmt->fetch()[0]; | |
$cleanGET = $_GET; | |
unset($cleanGET['buy']); | |
unset($cleanGET['payid']); | |
unset($cleanGET['refetch_index']); | |
?> | |
<!DOCTYPE html><html> | |
<head> | |
<meta charset="UTF-8" name="viewport" content="width=device-width"> | |
<meta name="format-detection" content="telephone=no" /> | |
<meta name="referrer" content="never"> | |
<title><?php echo str_replace(['<','>'],['<','>'], $epTitle);?> - 阅读 - bilibili漫画 阅读器 - BiliPlus</title> | |
<script src="materialize.min.js"></script> | |
<link rel="stylesheet" href="materialize.min.css" /> | |
<style> | |
body {width:100%;margin:5px 0;background:#EFEFF4;cursor:default;touch-action:manipulation;-webkit-text-size-adjust:none;} | |
.middle,#hoz-container:not(.hoz-container){width:95%;max-width:800px;margin:0 auto} | |
.comic-single{max-width:700px} | |
.hoz-container{direction: rtl;overflow-x:scroll;overflow-y:hidden;width:100%;min-width:750px;-webkit-overflow-scrolling:touch;transform-origin:0 0} | |
.hoz-container>div{text-align:center;width:<?php echo count($tokens['data']) * 704;?>px;direction:rtl;margin-right:calc(50% - 350px)} | |
.hoz-container .comic-single{vertical-align:top} | |
@media (prefers-color-scheme: dark) { | |
body {background:black;color:#DDD} | |
img {filter:brightness(0.8)} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="col s12"> | |
<div class="middle"> | |
<h5><?php echo str_replace(['<','>'],['<','>'], $epTitle);?></h5> | |
<?php if (!empty($epFullTitle)) echo '<h6>'. $epFullTitle .'</h6>' ?> | |
<p>索引缓存于 <?php echo date('Y/m/d H:i:s', $idxTime)?></p> | |
<p>© Copyright <a href="https://manga.bilibili.com/m/mc<?php echo $_GET['mangaid']?>/<?php echo $_GET['epid']?>" target="_blank">bilibili</a></p> | |
<p><!-- | |
|--><a href="/html/reply.htm#type=29&id=<?php echo $_GET['epid']?>&title=<?php echo rawurlencode($epTitle)?>" target="_blank">评论区</a><span class="review"></span> | <!-- | |
|--><a href="?<?php echo http_build_query($cleanGET);?>&refetch_index">刷新索引</a> | <!-- | |
|--><a href="javascript:toggleScrolling();">切换滚动</a><?php echo $chapLink;?><!-- | |
|--></p> | |
<p> | |
<label style="color:inherit"><input type="checkbox" id="hozScrollFix" style="opacity:initial;position:initial;pointer-events:initial">横向滚动图片渲染修复</label> | |
<label style="color:inherit"><input type="checkbox" id="hozScrollHeight" style="opacity:initial;position:initial;pointer-events:initial">高度适配屏幕</label> | |
<label style="color:inherit"><input type="checkbox" id="reloadClickedPic" style="opacity:initial;position:initial;pointer-events:initial">重新加载点击的图片</label> | |
<label style="color:inherit"><select id="picFormatSelect" style="width:initial;display:initial;background-color:#CCCCCC;border-radius:initial;border-color:white;height:auto"> | |
<option selected disabled>图片质量</option> | |
<option value="jpg-1400w">jpg 1400宽度</option> | |
<option value="jpg-full">原图 全尺寸</option> | |
<option value="webp-1400w">webp 1400宽度</option> | |
<option value="webp-full">webp 全尺寸</option> | |
</select></label> | |
</p> | |
</div> | |
<div id="hoz-container"><div style="text-align:center;font-size:0"><!-- | |
<?php | |
for ($i=0; $i < count($tokens['data']); $i++) { | |
?> | |
|--><img class="comic-single" title="<?php echo str_pad($i+1, 3, '0', STR_PAD_LEFT);?>" width="<?php echo $sizeResized[$i][0]?>" height="<?php echo $sizeResized[$i][1]?>" _src="<?php echo wrapPicUrl($tokens['data'][$i]['url'].'?token='.$tokens['data'][$i]['token'].'&no_cache=1');?>"><!-- | |
<?php | |
} | |
?> | |
|--></div></div> | |
<div class="middle"><p><!-- | |
|--><a href="/html/reply.htm#type=29&id=<?php echo $_GET['epid']?>&title=<?php echo rawurlencode($epTitle)?>" target="_blank">评论区</a><span class="review"></span><?php echo $chapLink;?><!-- | |
|--></p></div> | |
</div> | |
<div class="fixed-action-btn"><div style="background:rgba(255,255,255,.6);text-align:center;font-family:Arial;color:#000;padding:2px 8px"><span id="page">1</span><br>/<br><?php echo count($tokens['data']);?></div></div> | |
<script> | |
function review_count(data){if(data.code==0){[].slice.call(document.getElementsByClassName('review')).forEach(function(i){i.textContent='('+data.data.count+')';})}} | |
window.img_lazyload=function(){var t=[].slice.call(document.getElementsByTagName("img"));t.forEach(function(t){if(t.hasAttribute("_src")){var e=t.getBoundingClientRect();e.bottom<-100||e.top>innerHeight+1000||e.right<-1000||e.left>innerWidth+100||(t.setAttribute("src",t.getAttribute("_src")),t.removeAttribute("_src"))}})},window.addEventListener("scroll",img_lazyload),window.addEventListener("resize",img_lazyload); | |
var singles = [].slice.call(document.getElementsByClassName("comic-single")); | |
var blankImg = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg=="; | |
singles.forEach(function (i) {i.addEventListener('click', imgViewSlide);i.addEventListener('load', hozFixLoad); i.addEventListener('error', loadError); if (i.offsetWidth < 700) i.src = blankImg}); | |
function loadError() { | |
this.loadFailed = true; | |
} | |
function imgViewSlide(e) { | |
if (reloadClickedPic.checked) { | |
reloadClickedPic.checked = false; | |
var src = this.src; | |
this.src = 'about:blank'; | |
setTimeout(function() { | |
e.target.src = src; | |
}, 500); | |
return; | |
} | |
if (this.loadFailed) { | |
delete this.loadFailed; | |
this.src = this.src; | |
return; | |
} | |
var isBottomPartClick = e.clientY > innerHeight / 2, target = isBottomPartClick ? this.nextElementSibling : this.previousElementSibling; | |
if (target) { | |
if (hozScrolling) { | |
var containerBox = hozScrollEle.getBoundingClientRect(), targetBox = target.getBoundingClientRect(); | |
hozScrollEle.scrollLeft += targetBox.left / hozScrollScale + targetBox.width / 2 / hozScrollScale - containerBox.left / hozScrollScale - containerBox.width / 2 / hozScrollScale; | |
if (hozScrollHeight.checked) window.scrollTo(0, hozScrollEle.offsetTop); | |
} else { | |
window.scrollTo(0, target.offsetTop); | |
} | |
} | |
} | |
window.addEventListener('scroll', function () { | |
if (hozScrolling) return; | |
var line = innerHeight / 2 + scrollY, page = 1; | |
for (var i=0; i<singles.length; i++) { | |
if (singles[i].offsetTop > line) break; | |
page = singles[i].title | 0; | |
} | |
document.getElementById('page').textContent = page; | |
}); | |
var hozScrollEle = document.getElementById('hoz-container'); | |
var hozScrolling = false; | |
var hozFixTimeout = null; | |
function hozFixLoad() { | |
if (!hozScrolling || hozFixTimeout) return; | |
hozFix(); | |
} | |
function hozFix() { | |
if (!hozScrollFix.checked) return; | |
hozFixTimeout = 0; | |
hozScrolling = false; | |
hozScrollEle.classList.toggle('hoz-container'); | |
hozScrollEle.offsetWidth; | |
hozScrollEle.classList.toggle('hoz-container'); | |
setTimeout(function (){ hozScrolling = true; }, 100); | |
} | |
hozScrollEle.addEventListener('scroll', function () { | |
if (!hozScrolling) return; | |
clearTimeout(hozFixTimeout); | |
hozFixTimeout = setTimeout(hozFix, 150); | |
img_lazyload(); | |
var line = innerWidth / 2, page = 1; | |
for (var i=0; i<singles.length; i++) { | |
var box = singles[i].getBoundingClientRect(); | |
if (box.left + box.width < line) break; | |
page = singles[i].title | 0; | |
} | |
document.getElementById('page').textContent = page; | |
hozScrollPos = hozScrollEle.scrollLeft; | |
}) | |
var hozScrollPos = 0, hozScrollScale = 1; | |
window.addEventListener('resize', function () { | |
if (hozScrolling) { | |
if (hozScrollHeight.checked) { | |
var scale = Math.min(innerHeight / hozScrollEle.offsetHeight, 1); | |
hozScrollScale = scale; | |
hozScrollEle.style.transform = 'scale('+scale+')'; | |
hozScrollEle.style.width = (100 / scale) + '%'; | |
} else { | |
hozScrollScale = 1; | |
hozScrollEle.style.transform = ''; | |
hozScrollEle.style.width = ''; | |
} | |
hozScrollEle.scrollLeft = hozScrollPos; | |
} | |
}) | |
function toggleScrolling() { | |
hozScrollEle.classList.toggle('hoz-container'); | |
hozScrolling = hozScrollEle.classList.contains('hoz-container'); | |
localStorage.hozScroll = hozScrolling ? 1 : 0; | |
hozScrollPos = hozScrollEle.scrollLeft; | |
} | |
if (localStorage.hozScroll == 1) toggleScrolling(); | |
hozScrollFix.checked = localStorage.hozScrollFix == 1; | |
hozScrollFix.addEventListener('change', function () { | |
localStorage.hozScrollFix = this.checked ? 1 : 0; | |
}); | |
hozScrollHeight.checked = localStorage.hozScrollHeight == 1; | |
window.addEventListener('load', function () { | |
window.dispatchEvent(new Event('resize')); | |
}); | |
hozScrollHeight.addEventListener('change', function () { | |
window.dispatchEvent(new Event('resize')); | |
localStorage.hozScrollHeight = this.checked ? 1 : 0; | |
}) | |
picFormatSelect.addEventListener('change', function () { | |
document.cookie = "manga_pic_format=" + this.value + "; path=/manga/; max-age=315360000" | |
}) | |
if (Date.now() - parseInt(localStorage.oauthTime) > 24 * 60 * 60 * 1000 || localStorage.oauthTime == undefined) { | |
var s = document.createElement('script'); | |
s.src = '/login?act=expiretime&days=10'; | |
document.body.appendChild(s); | |
s.remove(); | |
} | |
<?php | |
if ($reseturl) { | |
?> | |
history.replaceState('', '', '?<?php echo http_build_query($cleanGET);?>') | |
<?php | |
} | |
?> | |
</script> | |
<script src="https://www.biliplus.com/api/reply?isCount=1&oid=<?php echo $_GET['epid'];?>&type=29&jsonp=jsonp&callback=review_count&_=<?php echo time();?>" async></script> | |
<script>fetch(location.href+'&add_history')</script> | |
</body> | |
</html> | |
<?php | |
function decryptBiliComicIndex($data, $mangaid, $epid) { | |
if (substr($data, 0, 9) !== 'BILICOMIC') { | |
return false; | |
} | |
$body = substr($data, 9); | |
$size = strlen($body); | |
$out = str_repeat("\0", $size); | |
$key = [ | |
$epid & 0xff, | |
$epid >> 8 & 0xff, | |
$epid >> 16 & 0xff, | |
$epid >> 24 & 0xff, | |
$mangaid & 0xff, | |
$mangaid >> 8 & 0xff, | |
$mangaid >> 16 & 0xff, | |
$mangaid >> 24 & 0xff | |
]; | |
for ($i = 0; $i < $size; $i++) { | |
$byte = ord($body[$i]); | |
$byte ^= ($key[($i) % 8]); | |
$out[$i] = chr($byte); | |
} | |
return readBiliComicIndexZip(new MemoryStream($out)); | |
} | |
function readBiliComicIndexZip(MemoryStream $out) { | |
$out->littleEndian = true; | |
$out->position = $out->size - 22; | |
$signature = $out->readData(4); | |
$num = $out->short; | |
$start = $out->short; | |
$numRecords = $out->short; | |
$total = $out->short; | |
$cdsize = $out->long; | |
$cdoffset = $out->long; | |
$commentlen = $out->short; | |
$out->position = $cdoffset + 4 + 2 * 6; | |
$crc = $out->ulong; | |
$compressedSize = $out->long; | |
$uncompressedSize = $out->long; | |
$out->position = 26; | |
$fnameLen = $out->short; | |
$extraLen = $out->short; | |
$out->position += $fnameLen + $extraLen; | |
$compressedData = $out->readData($compressedSize); | |
$data = gzinflate($compressedData); | |
if (crc32($data) !== $crc) { | |
return false; | |
} | |
return $data; | |
} | |
abstract class Stream { | |
abstract protected function read($length); | |
abstract public function seek($position); | |
abstract public function position(); | |
abstract protected function getPos(); | |
abstract protected function setPos($pos); | |
public function __get($name) { | |
switch($name) { | |
case 'position': return $this->getPos(); | |
case 'bool': return $this->readBoolean(); | |
case 'byte': return $this->readData(1); | |
case 'short': return $this->readInt16(); | |
case 'ushort': return $this->readUint16(); | |
case 'long': return $this->readInt32(); | |
case 'ulong': return $this->readUint32(); | |
case 'longlong': return $this->readInt64(); | |
case 'ulonglong': return $this->readUint64(); | |
case 'float': return $this->readFloat(); | |
case 'double': return $this->readDouble(); | |
case 'string': return $this->readStringToNull(); | |
case 'line': return $this->readStringToReturn(); | |
default: throw new Exception("Access undefined field ${name} of class ".get_class($this)); | |
} | |
} | |
public function __set($name, $val) { | |
switch($name) { | |
case 'position': return $this->setPos($val); | |
default: throw new Exception("Assign value to undefined field ${name} of class ".get_class($this)); | |
} | |
} | |
public $littleEndian = false; | |
public $size; | |
public function readStringToNull() { | |
$s = ''; | |
while (ord($char = $this->read(1)) != 0) { | |
$s .= $char; | |
} | |
return $s; | |
} | |
public function readStringAt($pos) { | |
$current = $this->position; | |
$this->position = $pos; | |
$data = $this->string; | |
$this->position = $current; | |
return $data; | |
} | |
public function readStringToReturn() { | |
$s = ''; | |
while ($this->position < $this->size && ($char = $this->read(1)) != "\n") { | |
$s .= $char; | |
} | |
return trim($s,"\r"); | |
} | |
public function readBoolean() { | |
return ord($this->byte)>0; | |
} | |
public function readInt16() { | |
$uint = $this->readUint16(); | |
$sint = unpack('s', pack('S', $uint))[1]; | |
return $sint; | |
} | |
public function readUint16() { | |
$int = $this->read(2); | |
if (strlen($int) != 2) return 0; | |
return unpack($this->littleEndian?'v':'n', $int)[1]; | |
} | |
public function readInt32() { | |
$uint = $this->readUint32(); | |
$sint = unpack('l', pack('L', $uint))[1]; | |
return $sint; | |
} | |
public function readUint32() { | |
$int = $this->read(4); | |
if (strlen($int) != 4) return 0; | |
return unpack($this->littleEndian?'V':'N', $int)[1]; | |
} | |
public function readInt64() { | |
$uint = $this->readUint64(); | |
$sint = unpack('q', pack('Q', $uint))[1]; | |
return $sint; | |
} | |
public function readUint64() { | |
$int = $this->read(8); | |
if (strlen($int) != 8) return 0; | |
return unpack($this->littleEndian?'P':'J', $int)[1]; | |
} | |
public function readFloat() { | |
$int = $this->read(4); | |
if (strlen($int) != 4) return 0; | |
if (!$this->littleEndian) $int = $int[3].$int[2].$int[1].$int[0]; | |
return unpack(/*$this->littleEndian?'g':'G'*/ 'f', $int)[1]; | |
} | |
public function readDouble() { | |
$int = $this->read(8); | |
if (strlen($int) != 8) return 0; | |
if (!$this->littleEndian) $int = $int[7].$int[6].$int[5].$int[4].$int[3].$int[2].$int[1].$int[0]; | |
return unpack(/*$this->littleEndian?'e':'E'*/ 'd', $int)[1]; | |
} | |
public function readData($size) { | |
if ($size <= 0) return ''; | |
return $this->read($size); | |
} | |
public function readDataAt($pos, $size) { | |
$current = $this->position; | |
$this->position = $pos; | |
$data = $this->readData($size); | |
$this->position = $current; | |
return $data; | |
} | |
public function alignStream($alignment) { | |
$mod = $this->position % $alignment; | |
if ($mod != 0) { | |
$this->position += $alignment - $mod; | |
} | |
} | |
public function readAlignedString($len) { | |
$string = $this->readData($len); | |
$this->alignStream(4); | |
return $string; | |
} | |
} | |
class MemoryStream extends Stream { | |
private $data; | |
private $offset; | |
function __construct($data) { | |
$this->data = $data; | |
$this->size = strlen($data); | |
} | |
function __destruct() { | |
$this->data = NULL; | |
} | |
protected function read($length) { | |
$data = substr($this->data, $this->offset, $length); | |
$this->offset += $length; | |
return $data; | |
} | |
public function seek($position) { | |
$this->offset = $position; | |
} | |
public function write($newData) { | |
$this->data .= $newData; | |
$this->size += strlen($newData); | |
} | |
public function position() { | |
return $this->offset; | |
} | |
protected function getPos() { | |
return $this->offset; | |
} | |
protected function setPos($pos) { | |
$this->offset = $pos; | |
} | |
} |
<?php | |
if (empty($_GET['albumid']) || !preg_match('(^\d+$)', $_GET['albumid'])) { | |
errordie('无效albumid'); | |
} | |
$curl = curl_init(); | |
chdir(__DIR__); | |
$idxDb = new PDO('sqlite:index.db'); | |
$reseturl = false; | |
$infoStmt = $idxDb->prepare('SELECT a.*,b.data FROM album_info AS a,album_pic_data AS b WHERE a.id=? AND a.id=b.album_id AND a.pic_hash=b.hash'); | |
$infoStmt->execute([$_GET['albumid']]); | |
$info = $infoStmt->fetch(PDO::FETCH_ASSOC); | |
if (empty($info)) { | |
errordie('还未取得此特典信息'); | |
} | |
$epTitle = $info['title']; | |
$epFullTitle = $info['detail']; | |
$mangaid = $info['manga_id']; | |
$picPaths = json_decode(brotli_uncompress($info['data']), true); | |
$picInfoStmt = $idxDb->prepare('SELECT * FROM pic_size_info WHERE path IN ('.implode(',',array_map(function () {return '?';},$picPaths)).')'); | |
$picInfoStmt->execute($picPaths); | |
$picInfo = []; | |
while ($row = $picInfoStmt->fetch(PDO::FETCH_ASSOC)) { | |
$picInfo[$row['path']] = [$row['w'], $row['h']]; | |
} | |
$picLackInfo = array_filter($picPaths, function ($p) use($picInfo) { return !isset($picInfo[$p]); }); | |
if (!empty($picLackInfo)) { | |
$tokens = json_decode(cget('http://manga.bilibili.com/twirp/comic.v1.Comic/ImageToken', [ | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'urls'=>json_encode(array_map(function ($p) {return 'http://'.IMG_HOST.$p.'@999999w.jpg';}, $picLackInfo)), | |
'version'=>APPVER, | |
], $appsecret), | |
'curl' => $curl | |
]), true); | |
if (!$tokens || $tokens['code'] != 0) { | |
errordie('获取凭据出错: ['.$tokens['code'].']'.$tokens['msg']); | |
} | |
$picSrcInfo = []; | |
header('Content-Encoding: identity'); | |
?> | |
<!DOCTYPE html><html> | |
<head> | |
<meta charset="UTF-8" name="viewport" content="width=device-width,user-scalable=no"> | |
<meta name="format-detection" content="telephone=no" /> | |
<meta name="referrer" content="never"> | |
<title>获取图片信息 - 书籍 - bilibili漫画 阅读器 - BiliPlus</title> | |
<script src="materialize.min.js"></script> | |
<link rel="stylesheet" href="materialize.min.css" /> | |
<style> | |
body {width:95%;max-width:700px;margin:5px auto;background:#EFEFF4;cursor:default} | |
@media (prefers-color-scheme: dark) { | |
body {background:black;color:#DDD} | |
img {filter:brightness(0.8)} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="col s12"> | |
<h3><?php echo $epTitle;?></h3> | |
<p>正在获取图片长宽信息(共<?php echo count($picLackInfo)?>张)</p> | |
<p> | |
<?php | |
ob_flush(); | |
flush(); | |
$queue = curl_multi_init(); | |
$map = []; | |
$imgCurl = curl_init(); | |
curl_setopt_array($imgCurl, [ | |
CURLOPT_HEADER=>true, | |
CURLOPT_RETURNTRANSFER=>true, | |
CURLOPT_HTTPHEADER=>['Range: bytes=0-0'] | |
]); | |
$failed = []; | |
$insertInfo = []; | |
$i=0; $total = count($picLackInfo); | |
foreach ($tokens['data'] as $item) { | |
$ch = curl_copy_handle($imgCurl); | |
curl_setopt($ch, CURLOPT_URL, str_replace('https:','http:',$item['url']).'?token='.$item['token']); | |
preg_match('(//[^/]+([^\?@]+))', $item['url'], $m); | |
$map[(string)$ch] = $m[1]; | |
curl_multi_add_handle($queue, $ch); | |
} | |
do { | |
while (($code = curl_multi_exec($queue, $active)) == CURLM_CALL_MULTI_PERFORM) ; | |
if ($code != CURLM_OK) { break; } else { usleep(1e3); } | |
while ($done = curl_multi_info_read($queue)) { | |
$i++; | |
$path = $map[(string)$done['handle']]; | |
$result = curl_multi_getcontent($done['handle']); | |
curl_multi_remove_handle($queue, $done['handle']); | |
curl_close($done['handle']); | |
$headersArr = explode("\r\n", substr($result, 0, strpos($result, "\r\n\r\n"))); | |
$headers = []; | |
foreach ($headersArr as $h) { | |
$idx = strpos($h, ': '); | |
if ($idx !== false) { | |
$headers[strtolower(substr($h, 0, $idx))] = substr(trim($h), $idx + 2); | |
} | |
} | |
echo "\n <div>".$i.'/'.$total.' '.$path.'...'; | |
if (empty($headers['o-width']) || empty($headers['o-height'])) { | |
echo "失败</div>"; | |
@ob_flush();@flush(); | |
$failed[] = $path; | |
continue; | |
} | |
$insertInfo[] = [$path, $headers['o-width'], $headers['o-height']]; | |
$picInfo[$path] = [$headers['o-width'], $headers['o-height']]; | |
echo $headers['o-width'].'x'.$headers['o-height'].'</div>'; | |
@ob_flush();@flush(); | |
} | |
if ($active > 0) { | |
curl_multi_select($queue, 0.5); | |
} | |
} while ($active > 0); | |
curl_multi_close($queue); | |
$picInfoInsertStmt = $idxDb->prepare('INSERT OR IGNORE INTO pic_size_info (path,w,h) VALUES (?,?,?)'); | |
$idxDb->beginTransaction(); | |
for ($i=0; $i<count($insertInfo); $i++) { | |
$picInfoInsertStmt->execute($insertInfo[$i]); | |
} | |
$idxDb->commit(); | |
if (!empty($failed)) { | |
echo '<p>获取图片长宽时出现错误,请刷新重试</p>'; | |
} else { | |
echo '<p>正在进入阅读页……</p><meta http-equiv="refresh" content="0" />'; | |
} | |
?> | |
</div> | |
</body> | |
</html> | |
<?php | |
exit; | |
} | |
$urls = []; | |
$sizeResized = []; | |
$setWidth = 700; | |
$picFormat = 'jpg'; | |
$fullSizePic = false; | |
if (isset($_COOKIE['manga_pic_format'])) { | |
switch ($_COOKIE['manga_pic_format']) { | |
case 'jpg-1400w': break; | |
case 'jpg-full': $fullSizePic = true; break; | |
case 'webp-1400w': $picFormat = 'webp'; break; | |
case 'webp-full': $picFormat = 'webp'; $fullSizePic = true; break; | |
} | |
} | |
for ($i=0; $i<count($picPaths); $i++) { | |
$append = '@'.($setWidth*2)."w.$picFormat"; | |
if ($fullSizePic) { | |
$append = "@.$picFormat"; | |
} else if ($picInfo[$picPaths[$i]][0] <= $setWidth * 2) { | |
$append = "@.$picFormat"; | |
} | |
$urls[] = 'https://'.IMG_HOST.$picPaths[$i].$append; | |
$sizeResized[] = [ | |
$setWidth, | |
$setWidth / $picInfo[$picPaths[$i]][0] * $picInfo[$picPaths[$i]][1] | |
]; | |
} | |
$tokens = json_decode(cget('http://manga.bilibili.com/twirp/comic.v1.Comic/ImageToken', [ | |
'post' => buildParam([ | |
'access_key'=>$_COOKIE['access_key'], | |
'actionKey'=>'appkey', | |
'appkey'=>$appkey, | |
'build'=>APPBUILD, | |
'device'=>'phone', | |
'mobi_app'=>'iphone_comic', | |
'platform'=>'ios', | |
'ts'=>time(), | |
'urls'=>json_encode($urls), | |
'version'=>APPVER, | |
], $appsecret), | |
'curl' => $curl | |
]), true); | |
if (!$tokens || $tokens['code'] != 0) { | |
errordie('获取凭据出错: ['.$tokens['code'].']'.$tokens['msg']); | |
} | |
$cleanGET = $_GET; | |
unset($cleanGET['buy']); | |
unset($cleanGET['payid']); | |
unset($cleanGET['refetch_index']); | |
?> | |
<!DOCTYPE html><html> | |
<head> | |
<meta charset="UTF-8" name="viewport" content="width=device-width"> | |
<meta name="format-detection" content="telephone=no" /> | |
<meta name="referrer" content="never"> | |
<title><?php echo str_replace(['<','>'],['<','>'], $epTitle);?> - 阅读 - bilibili漫画 阅读器 - BiliPlus</title> | |
<script src="materialize.min.js"></script> | |
<link rel="stylesheet" href="materialize.min.css" /> | |
<style> | |
body {width:100%;margin:5px 0;background:#EFEFF4;cursor:default;touch-action:manipulation;-webkit-text-size-adjust:none;} | |
.middle,#hoz-container:not(.hoz-container){width:95%;max-width:800px;margin:0 auto} | |
.comic-single{max-width:700px} | |
.hoz-container{direction: rtl;overflow-x:scroll;overflow-y:hidden;width:100%;min-width:750px;-webkit-overflow-scrolling:touch;transform-origin:0 0} | |
.hoz-container>div{text-align:center;width:<?php echo count($tokens['data']) * 704;?>px;direction:rtl;margin-right:calc(50% - 350px)} | |
.hoz-container .comic-single{vertical-align:top} | |
@media (prefers-color-scheme: dark) { | |
body {background:black;color:#DDD} | |
img {filter:brightness(0.8)} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="col s12"> | |
<div class="middle"> | |
<h5><?php echo str_replace(['<','>'],['<','>'], $epTitle);?></h5> | |
<?php if (!empty($epFullTitle)) echo '<h6>'. $epFullTitle .'</h6>' ?> | |
<p>© Copyright <a href="https://manga.bilibili.com/m/detail/mc<?php echo $mangaid?>" target="_blank">bilibili</a></p> | |
<p><!-- | |
|--><a href="javascript:toggleScrolling();">切换滚动</a><!-- | |
|--></p> | |
<p> | |
<label style="color:inherit"><input type="checkbox" id="hozScrollFix" style="opacity:initial;position:initial;pointer-events:initial">横向滚动图片渲染修复</label> | |
<label style="color:inherit"><input type="checkbox" id="hozScrollHeight" style="opacity:initial;position:initial;pointer-events:initial">高度适配屏幕</label> | |
<label style="color:inherit"><input type="checkbox" id="reloadClickedPic" style="opacity:initial;position:initial;pointer-events:initial">重新加载点击的图片</label> | |
</p> | |
</div> | |
<div id="hoz-container"><div style="text-align:center;font-size:0"><!-- | |
<?php | |
for ($i=0; $i < count($tokens['data']); $i++) { | |
?> | |
|--><img class="comic-single" title="<?php echo str_pad($i+1, 3, '0', STR_PAD_LEFT);?>" width="<?php echo $sizeResized[$i][0]?>" height="<?php echo $sizeResized[$i][1]?>" _src="<?php echo wrapPicUrl($tokens['data'][$i]['url'].'?token='.$tokens['data'][$i]['token'].'&no_cache=1');?>"><!-- | |
<?php | |
} | |
?> | |
|--></div></div> | |
</div> | |
<div class="fixed-action-btn"><div style="background:rgba(255,255,255,.6);text-align:center;font-family:Arial;color:#000;padding:2px 8px"><span id="page">1</span><br>/<br><?php echo count($tokens['data']);?></div></div> | |
<script> | |
window.img_lazyload=function(){var t=[].slice.call(document.getElementsByTagName("img"));t.forEach(function(t){if(t.hasAttribute("_src")){var e=t.getBoundingClientRect();e.bottom<-100||e.top>innerHeight+1000||e.right<-1000||e.left>innerWidth+100||(t.setAttribute("src",t.getAttribute("_src")),t.removeAttribute("_src"))}})},window.addEventListener("scroll",img_lazyload),window.addEventListener("resize",img_lazyload); | |
var singles = [].slice.call(document.getElementsByClassName("comic-single")); | |
var blankImg = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg=="; | |
singles.forEach(function (i) {i.addEventListener('click', imgViewSlide);i.addEventListener('load', hozFixLoad); i.addEventListener('error', loadError); if (i.offsetWidth < 700) i.src = blankImg}); | |
function loadError() { | |
this.loadFailed = true; | |
} | |
function imgViewSlide(e) { | |
if (reloadClickedPic.checked) { | |
reloadClickedPic.checked = false; | |
var src = this.src; | |
this.src = 'about:blank'; | |
setTimeout(function() { | |
e.target.src = src; | |
}, 500); | |
return; | |
} | |
if (this.loadFailed) { | |
delete this.loadFailed; | |
this.src = this.src; | |
return; | |
} | |
var isBottomPartClick = e.clientY > innerHeight / 2, target = isBottomPartClick ? this.nextElementSibling : this.previousElementSibling; | |
if (target) { | |
if (hozScrolling) { | |
var containerBox = hozScrollEle.getBoundingClientRect(), targetBox = target.getBoundingClientRect(); | |
hozScrollEle.scrollLeft += targetBox.left / hozScrollScale + targetBox.width / 2 / hozScrollScale - containerBox.left / hozScrollScale - containerBox.width / 2 / hozScrollScale; | |
if (hozScrollHeight.checked) window.scrollTo(0, hozScrollEle.offsetTop); | |
} else { | |
window.scrollTo(0, target.offsetTop); | |
} | |
} | |
} | |
window.addEventListener('scroll', function () { | |
if (hozScrolling) return; | |
var line = innerHeight / 2 + scrollY, page = 1; | |
for (var i=0; i<singles.length; i++) { | |
if (singles[i].offsetTop > line) break; | |
page = singles[i].title | 0; | |
} | |
document.getElementById('page').textContent = page; | |
}); | |
var hozScrollEle = document.getElementById('hoz-container'); | |
var hozScrolling = false; | |
var hozFixTimeout = null; | |
function hozFixLoad() { | |
if (!hozScrolling || hozFixTimeout) return; | |
hozFix(); | |
} | |
function hozFix() { | |
if (!hozScrollFix.checked) return; | |
hozFixTimeout = 0; | |
hozScrolling = false; | |
hozScrollEle.classList.toggle('hoz-container'); | |
hozScrollEle.offsetWidth; | |
hozScrollEle.classList.toggle('hoz-container'); | |
setTimeout(function (){ hozScrolling = true; }, 100); | |
} | |
hozScrollEle.addEventListener('scroll', function () { | |
if (!hozScrolling) return; | |
clearTimeout(hozFixTimeout); | |
hozFixTimeout = setTimeout(hozFix, 150); | |
img_lazyload(); | |
var line = innerWidth / 2, page = 1; | |
for (var i=0; i<singles.length; i++) { | |
var box = singles[i].getBoundingClientRect(); | |
if (box.left + box.width < line) break; | |
page = singles[i].title | 0; | |
} | |
document.getElementById('page').textContent = page; | |
hozScrollPos = hozScrollEle.scrollLeft; | |
}) | |
var hozScrollPos = 0, hozScrollScale = 1; | |
window.addEventListener('resize', function () { | |
if (hozScrolling) { | |
if (hozScrollHeight.checked) { | |
var scale = Math.min(innerHeight / hozScrollEle.offsetHeight, 1); | |
hozScrollScale = scale; | |
hozScrollEle.style.transform = 'scale('+scale+')'; | |
hozScrollEle.style.width = (100 / scale) + '%'; | |
} else { | |
hozScrollScale = 1; | |
hozScrollEle.style.transform = ''; | |
hozScrollEle.style.width = ''; | |
} | |
hozScrollEle.scrollLeft = hozScrollPos; | |
} | |
}) | |
function toggleScrolling() { | |
hozScrollEle.classList.toggle('hoz-container'); | |
hozScrolling = hozScrollEle.classList.contains('hoz-container'); | |
localStorage.hozScroll = hozScrolling ? 1 : 0; | |
hozScrollPos = hozScrollEle.scrollLeft; | |
} | |
if (localStorage.hozScroll == 1) toggleScrolling(); | |
hozScrollFix.checked = localStorage.hozScrollFix == 1; | |
hozScrollFix.addEventListener('change', function () { | |
localStorage.hozScrollFix = this.checked ? 1 : 0; | |
}); | |
hozScrollHeight.checked = localStorage.hozScrollHeight == 1; | |
window.addEventListener('load', function () { | |
window.dispatchEvent(new Event('resize')); | |
}); | |
hozScrollHeight.addEventListener('change', function () { | |
window.dispatchEvent(new Event('resize')); | |
localStorage.hozScrollHeight = this.checked ? 1 : 0; | |
}) | |
if (Date.now() - parseInt(localStorage.oauthTime) > 24 * 60 * 60 * 1000 || localStorage.oauthTime == undefined) { | |
var s = document.createElement('script'); | |
s.src = '/login?act=expiretime&days=10'; | |
document.body.appendChild(s); | |
s.remove(); | |
} | |
<?php | |
if ($reseturl) { | |
?> | |
history.replaceState('', '', '?<?php echo http_build_query($cleanGET);?>') | |
<?php | |
} | |
?> | |
</script> | |
</body> | |
</html> |
/** @type{Cache} */ | |
let normalCache | |
/** @type{Cache} */ | |
let readerCache | |
/** @type{Cache} */ | |
let readerResCache | |
const NORMAL = 'normal' | |
const READER = 'reader' | |
const READER_RES = 'reader-res' | |
const VERSION = 2 | |
let enable = true | |
async function initCache() { | |
normalCache = await caches.open(NORMAL) | |
readerCache = await caches.open(READER) | |
readerResCache = await caches.open(READER_RES) | |
} | |
/** | |
* @param {Headers} h | |
* @returns {Headers} | |
*/ | |
function cloneHeaders(h) { | |
const c = new Headers() | |
for (const k of h.keys()) { | |
c.set(k, h.get(k)) | |
} | |
return c | |
} | |
const cacheQueue = {} | |
/** | |
* | |
* @param {Request} req | |
* @param {Response} res | |
*/ | |
async function checkAndAddCache(req, res) { | |
const url = req.url | |
if (cacheQueue[url]) { | |
const queue = cacheQueue[url] | |
delete cacheQueue[url] | |
const headerWithTime = cloneHeaders(req.headers) | |
headerWithTime.set('sw-cache-time', Date.now()) | |
req = new Request(req.url, { headers: headerWithTime }) | |
queue.cache.put(req, res.clone()) | |
queue.client.postMessage({cmd: 'cached', url}) | |
} else if (shouldPutNormalCache(req)) { | |
const headerWithTime = cloneHeaders(req.headers) | |
headerWithTime.set('sw-cache-time', Date.now()) | |
req = new Request(req.url, { headers: headerWithTime }) | |
const normalCache = await caches.open(NORMAL) | |
normalCache.put(req, res.clone()) | |
} | |
} | |
/** | |
* @param {Request} req | |
*/ | |
function shouldPutNormalCache(req) { | |
if (req.mode == 'navigate') return true | |
if (['script', 'style'].indexOf(req.destination) !== -1) return true | |
if (req.destination == 'image') { | |
if (/hdslb\.com/.test(req.url) && !/\/manga\//.test(req.url)) return true | |
} | |
return false | |
} | |
/** | |
* @param {Cache} cache | |
* @returns {Promise<string[]>} | |
*/ | |
function clearCache(cache) { | |
return cache.keys().then(keys => ( | |
Promise.all(keys.map(key => cache.delete(key).then(b => key.url))) | |
)) | |
} | |
function clearAllCache() { | |
return Promise.all([NORMAL, READER, READER_RES].map(n => ( | |
caches.open(n).then(clearCache).then(r => ([n, r])) | |
))) | |
} | |
/** | |
* @param {Cache} cache | |
*/ | |
function clearCacheBefore(cache, time) { | |
return cache.keys().then(keys => ( | |
Promise.all(keys.filter(key => (key.headers.get('sw-cache-time') < time)).map(key => cache.delete(key).then(b => key.url))) | |
)) | |
} | |
/** | |
* | |
* @param {FetchEvent} e | |
* @returns {Promise<Response>} | |
*/ | |
async function processFetchEvent(e) { | |
const forcedCacheEntry = await caches.match(e.request, {cacheName: READER}) | |
if (forcedCacheEntry) return forcedCacheEntry.clone() | |
const forcedResCacheEntry = await caches.match(e.request, {cacheName: READER_RES}) | |
if (forcedResCacheEntry) return forcedResCacheEntry.clone() | |
let netErr = null | |
const netResponse = await fetch(e.request).catch(e => netErr = e) | |
if (!netErr && (netResponse.ok || netResponse.type == 'opaque')) { | |
await checkAndAddCache(e.request, netResponse) | |
return netResponse | |
} else { | |
const backupCacheEntry = await caches.match(e.request, {cacheName: NORMAL}) | |
if (backupCacheEntry) { | |
return backupCacheEntry.clone() | |
} else { | |
if (netErr) throw netErr | |
return netResponse | |
} | |
} | |
} | |
self.addEventListener('fetch', async e => { | |
if (!enable) return | |
e.respondWith(processFetchEvent(e)) | |
}) | |
self.addEventListener('install', async e => { | |
self.skipWaiting() | |
}) | |
self.addEventListener('activate', async e => { | |
e.waitUntil(initCache()) | |
}) | |
self.addEventListener('message', async e => { | |
const client = e.source | |
const msg = e.data | |
switch (msg.cmd) { | |
// request worker version | |
case 'version': { | |
client.postMessage({cmd: 'version', data: VERSION}) | |
client.postMessage({cmd: 'enable-status'}) | |
return | |
} | |
// update enable status | |
// { cmd: 'enable-status', enable: bool } | |
case 'enable-status': { | |
enable = msg.enable | |
if (!enable) { | |
const clearedEntries = Object.fromEntries(await clearAllCache()) | |
client.postMessage({cmd: 'cleared-cache', entries: clearedEntries}) | |
} | |
return | |
} | |
// cache next fetched reader web page | |
// { cmd: 'cache-reader-page', url: page_url } | |
case 'cache-reader-page': { | |
let cache = await caches.open(READER) | |
cacheQueue[msg.url] = {client, cache} | |
return | |
} | |
// cache next fetched reader resources | |
// { cmd: 'cache-reader-resources', urls: [page_img_url] } | |
case 'cache-reader-resources': { | |
let cache = await caches.open(READER_RES) | |
msg.urls.forEach(url => { | |
cacheQueue[url] = {client, cache} | |
}) | |
return | |
} | |
// clear expired cache | |
// { cmd: 'clear-stale-cache', keys: {key: day} } | |
case 'clear-stale-cache': { | |
const clearedEntries = {} | |
await Promise.all(Object.keys(msg.data).map(async cacheKey => { | |
const cache = await caches.open(cacheKey) | |
clearedEntries[cacheKey] = await clearCacheBefore(cache, Date.now() - msg.data.keys[cacheKey] * 24 * 3600e3) | |
})) | |
client.postMessage({cmd: 'cleared-cache', entries: clearedEntries}) | |
return | |
} | |
// clear all cache | |
// { cmd: 'clear-stale-cache'} | |
case 'clear-all-cache': { | |
const clearedEntries = Object.fromEntries(await clearAllCache()) | |
client.postMessage({cmd: 'cleared-cache', entries: clearedEntries}) | |
return | |
} | |
// clear cache entries | |
// { cmd: 'clear-stale-cache', keys: {key: [url]} } | |
case 'clear-cache-entries': { | |
const clearedEntries = {} | |
await Promise.all(Object.keys(msg.keys).map(async cacheKey => { | |
const cache = await caches.open(cacheKey) | |
clearedEntries[cacheKey] = await Promise.all(msg.keys[cacheKey].map(i => ( | |
delete cacheQueue[i], cache.delete(i).then(b => i) | |
))) | |
})) | |
client.postMessage({cmd: 'cleared-cache', entries: clearedEntries}) | |
return | |
} | |
} | |
}) |
<style> | |
#sw_control { | |
position: fixed; | |
top: 10px; | |
right: 20px; | |
background: #EFEFF4; | |
width: 250px; | |
height: 170px; | |
padding: 10px; | |
border: dashed 1px; | |
border-radius: 5px; | |
z-index: 10; | |
} | |
#sw_control.hide { | |
display: none | |
} | |
@media (prefers-color-scheme: dark) { | |
#sw_control { | |
background: black; | |
} | |
} | |
#sw_control.no-estimate #cache_size_container{display:none} | |
.cache-entry-row { | |
display:flex; | |
} | |
.cache-entry-row>* { | |
flex:0.1; | |
} | |
.cache-entry.downloading input { | |
pointer-events: none; | |
visibility: hidden; | |
} | |
.cache-entry .cache-episode-name { | |
flex: 1; | |
overflow: hidden; | |
white-space: pre; | |
text-overflow: ellipsis; | |
} | |
.cache-entry:not(.downloading) .cache-progress-text { | |
display:none; | |
} | |
.cache-entry:not(.downloading) .cache-progress-bar { | |
display:none; | |
} | |
.cache-progress-bar { | |
display: block; | |
flex: 0; | |
width: 100%; | |
height: 2px; | |
} | |
.cache-entry:not(.with-progress) .cache-progress-bar { | |
background-image: repeating-linear-gradient(90deg,rgb(69,208,30),rgb(69,208,30) 50%,transparent 50%,transparent 100%); | |
background-size: calc(100% / 3); | |
animation:cache_progress_anim 2s linear infinite; | |
} | |
.cache-entry.with-progress .cache-progress-bar { | |
background: rgb(69,208,30); | |
width:0; | |
} | |
@keyframes cache_progress_anim { | |
0% { | |
background-position: 0; | |
} | |
100% { | |
background-position: 100%; | |
} | |
} | |
#sw_cache_entries { | |
height: calc(150px - 4.5em); | |
overflow: hidden auto; | |
} | |
#sw_control.no-estimate #sw_cache_entries { | |
height: calc(150px - 3em); | |
} | |
</style> | |
<span id="sw_control_toggle_container" style="display:none"> | |
<label style="color:inherit"><input id="sw_control_toggle" autocomplete="off" type="checkbox" style="opacity:initial;position:initial;pointer-events:initial">显示缓存控制</label> | |
</span> | |
<div id="sw_control" class="hide"> | |
<div>缓存:</div> | |
<div id="cache_size_container">占用空间:<span id="cache_size">-- / --</span></div> | |
<div> | |
<label style="color:inherit"><input id="sw_toggle" autocomplete="off" type="checkbox" style="opacity:initial;position:initial;pointer-events:initial">启用缓存</label> | |
<input id="deleteCacheBtn" autocomplete="off" disabled type="button" value="删除选中缓存"> | |
</div> | |
<div id="sw_cache_entries"> </div> | |
</div> | |
<script> | |
'use strict' | |
var swReg | |
if (navigator.serviceWorker) { | |
// https://stackoverflow.com/a/34699901 | |
function de(b){var a,e={},d=b.split(""),f,c=f=d[0],g=[c],o,h=o=256;for(b=1;b<d.length;b++)a=d[b].charCodeAt(0),a=h>a?d[b]:e[a]?e[a]:f+c,g.push(a),c=a.charAt(0),e[o]=f+c,o++,f=a;return g.join("")} | |
function _(e,t,i){var a=null;if("text"===e)return document.createTextNode(t);a=document.createElement(e);for(var n in t)if("style"===n)for(var o in t.style)a.style[o]=t.style[o];else if("className"===n)a.className=t[n];else if("event"===n)for(var o in t.event)a.addEventListener(o,t.event[o]);else a.setAttribute(n,t[n]);if(i)if("string"==typeof i)a.innerHTML=i;else if(Array.isArray(i))for(var l=0;l<i.length;l++)null!=i[l]&&a.appendChild(i[l]);return a} | |
const NORMAL = 'normal' | |
const READER = 'reader' | |
const READER_RES = 'reader-res' | |
const swVer = 2 | |
const showSwControl = () => { | |
sw_control.classList.toggle('hide') | |
if (window.sw_action) { | |
sw_action.classList.toggle('hide') | |
} | |
} | |
const updateCacheSize = () => { | |
if (navigator.storage && navigator.storage.estimate) { | |
navigator.storage.estimate().then(s => { | |
const prettySize = s => { | |
const unit = Math.floor(Math.log2(s) / 10) | |
const num = unit > 0 ? (s / Math.pow(1024, unit)).toFixed(2) : s | |
return num + ['B', 'KiB', 'MiB', 'GiB'][unit] | |
} | |
cache_size.textContent = prettySize(s.usage) + ' / ' + prettySize(s.quota) | |
}) | |
} else { | |
sw_control.classList.add('no-estimate') | |
} | |
} | |
navigator.serviceWorker.getRegistration().then(function saveSwReg(reg) { | |
if (!reg) { | |
navigator.serviceWorker.register('/manga/sw.js') | |
navigator.serviceWorker.ready.then(saveSwReg) | |
return | |
} | |
swReg = reg | |
sw_control_toggle_container.style.display = '' | |
window.addEventListener('focus', () => { | |
sw_toggle.checked = localStorage.swToggle !== 'off' | |
updateCacheSize() | |
}) | |
sw_toggle.addEventListener('change', () => { | |
if (!sw_toggle.checked) { | |
const entries = [...document.querySelectorAll('.cache-entry')] | |
if (entries.length) { | |
if (!confirm('清空并禁用缓存吗?')) { | |
sw_toggle.checked = true | |
return | |
} | |
} | |
entries.forEach(i => i.remove()) | |
for (let k in localStorage) { | |
if (k.substr(0, 18) == 'manga_cache_entry_') { | |
delete localStorage[k] | |
} | |
} | |
} | |
localStorage.swToggle = sw_toggle.checked ? 'on' : 'off' | |
reg.active.postMessage({cmd: 'enable-status', enable: localStorage.swToggle !== 'off'}) | |
}) | |
sw_control_toggle.addEventListener('change', showSwControl) | |
window.dispatchEvent(new Event('focus')) | |
updateCacheSize() | |
navigator.serviceWorker.addEventListener('message', e => { | |
const msg = e.data | |
//console.log(e) | |
switch (msg.cmd) { | |
case 'version': { | |
if (msg.data !== swVer) { | |
console.log('update sw') | |
reg.update() | |
} | |
return | |
} | |
case 'enable-status': { | |
reg.active.postMessage({cmd: 'enable-status', enable: localStorage.swToggle !== 'off'}) | |
return | |
} | |
// { cmd: 'cleared-cache', keys: {key: day} } | |
case 'cleared-cache': { | |
//console.log('cleared-cache', msg.entries) | |
sleep(500).then(updateCacheSize) | |
return | |
} | |
// { cmd: 'cached', url: item } | |
case 'cached': { | |
sleep(500).then(updateCacheSize) | |
return | |
} | |
default: { | |
console.log(msg.cmd, msg) | |
} | |
} | |
}) | |
reg.active.postMessage({cmd: 'version'}) | |
reg.active.postMessage({cmd: 'enable-status', enable: localStorage.swToggle !== 'off'}) | |
reg.active.postMessage({cmd: 'clear-stale-cache', keys: { [NORMAL]: 7 }}) | |
}) | |
const cacheDeleteSelection = [] | |
function pickCacheToDelete(e) { | |
const box = e.target | |
const target = box.parentNode.parentNode.parentNode | |
const idx = cacheDeleteSelection.indexOf(target) | |
if (idx == -1) { | |
cacheDeleteSelection.push(target) | |
box.checked = true | |
} else { | |
cacheDeleteSelection.splice(idx, 1) | |
box.checked = false | |
} | |
deleteCacheBtn[cacheDeleteSelection.length ? 'removeAttribute' : 'setAttribute']('disabled', '') | |
} | |
deleteCacheBtn.addEventListener('click', () => { | |
cacheDeleteSelection.forEach(i => { | |
const k = i.dataset.key | |
const [url, info] = decodeCachedInfo(k) | |
swReg.active.postMessage({ cmd: 'clear-cache-entries', keys: { [READER]: [url], [READER_RES]: info.pages }}) | |
delete localStorage[k] | |
i.remove() | |
}) | |
cacheDeleteSelection.splice(0, cacheDeleteSelection.length) | |
}) | |
window.getCachedEpisodeEntryElement = function(key, info) { | |
return _('div', { className: 'cache-entry', 'data-key': key }, [ | |
_('div', { className: 'cache-entry-row' }, [ | |
_('div', {}, [_('input', { type: 'checkbox', event: { change: pickCacheToDelete }, style: {opacity:'initial', position:'initial', pointerEvents:'initial'}})]), | |
_('div', { className: 'cache-episode-name', title: info.ep +'-'+ info.manga }, [_('text', info.ep +'-'+ info.manga)]), | |
_('div', { className: 'cache-progress-text' }), | |
]), | |
_('div', { className: 'cache-progress-bar' }) | |
]) | |
} | |
function decodeCachedInfo(k) { | |
try { | |
const info = JSON.parse(unescape(de(localStorage[k]))) | |
return [k.substr(18), info] | |
} catch (e) { | |
const info = JSON.parse(localStorage[k]) | |
return [decodeURIComponent(k.substr(18)), info] | |
} | |
} | |
Object.keys(localStorage).filter(k => k.substr(0, 18) == 'manga_cache_entry_').sort().forEach(k => { | |
const [url, info] = decodeCachedInfo(k) | |
const node = getCachedEpisodeEntryElement(k, info) | |
sw_cache_entries.appendChild(node) | |
}) | |
/** | |
* @param {number} msec | |
* @returns {Promise<void>} | |
*/ | |
window.sleep = function(msec) { | |
return new Promise((res, rej) => setTimeout(res, msec)) | |
} | |
window.validateCachedImage = function (url) { | |
return new Promise((res, rej) => { | |
const div = document.body.appendChild(_('div', {style:{position:'absolute',visibility:'hidden',pointerEvents:'none'}}, [ | |
_('img', {src:url, event:{load:e => { | |
if (e.target.offsetHeight > 0 && e.target.offsetWidth > 0) { | |
div.remove() | |
return res(true) | |
} | |
div.remove() | |
rej(false) | |
}, error: e=> { | |
div.remove() | |
rej(false) | |
}}}) | |
])) | |
}) | |
} | |
window.validateAllCache = async function () { | |
for (var k of Object.keys(localStorage).filter(k => k.substr(0, 18) == 'manga_cache_entry_').sort()) { | |
const [_, info] = decodeCachedInfo(k) | |
for (var url of info.pages) { | |
await validateCachedImage(url).catch(r=>{cache_message.textContent = `${info.epFull} ${url}\n`+cache_message.textContent}) | |
} | |
cache_message.textContent = `已验证 ${info.epFull}\n`+cache_message.textContent | |
} | |
} | |
} | |
</script> | |
<?php | |
if (!isset($_GET['act'])) { // fav page | |
/* | |
? > | |
<script> | |
if (navigator.serviceWorker) { | |
const NORMAL = 'normal' | |
const READER = 'reader' | |
const READER_RES = 'reader-res' | |
} | |
</script> | |
< ?php | |
*/ | |
} else if ($_GET['act'] == 'detail') { // comic detail page | |
?> | |
<style> | |
.cache_selected .contents-simple > a, .cache_selected .contents-full { | |
background-image: repeating-linear-gradient( | |
45deg, | |
rgba(80,80,80,0.4), | |
rgba(80,80,80,0.4) 5px, | |
rgba(150,150,150,0.4) 5px, | |
rgba(150,150,150,0.4) 10px | |
); | |
background-size: 999px 999px; | |
background-position: 50%; | |
animation: cache_bg_anim linear 10s infinite; | |
} | |
@keyframes cache_bg_anim { | |
0% { | |
background-position: 50%; | |
} | |
100% { | |
background-position: calc(50% + 141.42px); | |
} | |
} | |
#sw_action.hide { | |
display:none | |
} | |
#cache_message { | |
max-height: 5.5em; | |
white-space: pre-wrap; | |
overflow-y: auto; | |
-webkit-overflow-scrolling: touch; | |
} | |
</style> | |
<div id="sw_action" class="hide"> | |
<label style="color:inherit"><input id="choose_episode_toggle" autocomplete="off" type="checkbox" style="opacity:initial;position:initial;pointer-events:initial">选择缓存章节</label> | |
<input id="cache_episode" autocomplete="off" disabled type="button" style="opacity:initial;position:initial;pointer-events:initial" value="缓存"> | |
<input id="validate_caches" autocomplete="off" type="button" style="opacity:initial;position:initial;pointer-events:initial" value="验证缓存"> | |
<div id="cache_message"></div> | |
</div> | |
<script> | |
if (navigator.serviceWorker) setTimeout(() => { | |
// https://stackoverflow.com/a/34699901 | |
function en(c){var x='charCodeAt',b,e={},f=c.split(""),d=[],a=f[0],g=256;for(b=1;b<f.length;b++)c=f[b],null!=e[a+c]?a+=c:(d.push(1<a.length?e[a]:a[x](0)),e[a+c]=g,g++,a=c);d.push(1<a.length?e[a]:a[x](0));for(b=0;b<d.length;b++)d[b]=String.fromCharCode(d[b]);return d.join("")} | |
const NORMAL = 'normal' | |
const READER = 'reader' | |
const READER_RES = 'reader-res' | |
let choosingCacheEpisode = false | |
let working = false | |
let currentTaskInfo = null | |
contents.addEventListener('click', e => { | |
if (choosingCacheEpisode) { | |
e.stopPropagation() | |
e.preventDefault() | |
if (working) return | |
let target = e.target | |