// ==UserScript==
// @name 微信、知乎、掘金、简书,贴吧,文章页宽屏,贴吧签到
// @namespace http://tampermonkey.net/
// @version 1.1.1
// @description 微信、知乎、掘金、简书,贴吧,文章页宽屏,贴吧签到(模拟客户端获得更多经验)
// @author sakura-flutter
// @match https://mp.weixin.qq.com/s*
// @match https://zhuanlan.zhihu.com/p/*
// @match https://www.zhihu.com/question/*
// @match https://juejin.im/post/*
// @match https://www.jianshu.com/p/*
// @match https://tieba.baidu.com/p/*
// @match https://tieba.baidu.com
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @connect tieba.baidu.com
// @require https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.min.js
// @require https://cdn.jsdelivr.net/npm/md5/dist/md5.min.js
// @compatible chrome >= 80 firefox >= 75
// @homepage https://gist.github.com/sakura-flutter/116338dcd68ab99e50322aa0058a35a1
// ==/UserScript==
/* global Vue MD5 */
(function() {
'use strict'
// true|false 开启后会打开日志
const isDebug = false
const $ = document.querySelector.bind(document)
const $$ = document.querySelectorAll.bind(document)
function log(...args) {
if (!isDebug) return
console.log(...args)
}
// 主函数
function main() {
const sites = checkWebsites()
sites.forEach(site => {
const hanlder = handlers.get(site)
log(site)
hanlder && hanlder()
})
}
// 检查网站
function checkWebsites() {
const { origin, pathname } = location
const url = origin + pathname
// 格式[ ['xx', true|false], ]
const sites = [
['mpWeixin', /mp.weixin.qq.com\/s/.test(url)],
['zhihu', /zhuanlan.zhihu.com\/p\//.test(url) || /zhihu.com\/question\//.test(url)],
['juejin', /juejin.im\/post\//.test(url)],
['jianshu', /jianshu.com\/p\//.test(url)],
['tieba', /tieba.baidu.com\/p\//.test(url)],
['tiebaMain', url.endsWith('tieba.baidu.com/')],
]
// 返回匹配的页面
return sites
.filter(item => item[1])
.map(item => item[0])
}
// 对应网页要执行的操作操作
const handlers = new Map()
/* ===微信文章===start */
handlers.set('mpWeixin', function() {
const store = createStore('mpWeixin')
function execute() {
GM_addStyle(`
/* 文章宽屏 */
.rich_media_area_primary_inner { max-width: 100vw !important; }
/* 二维码位置 */
#js_pc_qr_code .qr_code_pc { position: fixed; top: 25vh; right: 3vw; }
@media screen and (min-width: 1024px) {
.rich_media_area_primary_inner { max-width: 75vw !important; }
#js_pc_qr_code .qr_code_pc { position: fixed; top: 25vh; right: 3vw; }
}
`)
// 文章图片宽高(仅对大图处理)
const imgEls = $$('.rich_media_area_primary_inner img')
imgEls.forEach(img => {
img.addEventListener('load', () => {
// 页面本身对图片有宽高处理,延时后再处理
setTimeout(() => {
const width = parseFloat(getComputedStyle(img).width)
if (width >= 400) {
img.style.cssText += 'width: auto !important; height: auto !important;'
}
},16)
})
})
Toast.info('已宽屏处理')
}
createWidescreenControl({ store, execute })
})
/* ===微信文章===end */
/* ===知乎===start */
handlers.set('zhihu', function() {
const store = createStore('zhihu')
function execute() {
GM_addStyle(`
/* 知乎专栏 */
.Post-NormalMain .Post-Header, .Post-NormalMain>div, .Post-NormalSub>div {
width: 65vw;
min-width: 690px;
}
.Post-SideActions { left: calc((100vw - 82vw)/2); }
/* 知乎问答 */
.QuestionHeader-content, .QuestionHeader-footer {
width: 75vw;
min-width: 1000px;
margin-left: auto;
margin-right: auto;
}
.QuestionHeader-footer-inner {
width: auto;
}
.QuestionHeader-footer-main {
padding-left: 0;
}
.QuestionHeader-main {
width: auto;
flex: 1;
}
.Question-main {
width: 75vw;
min-width: 1000px;
}
.Question-main .ListShortcut {
flex: 1;
}
.Question-mainColumn {
flex: 1;
width: auto;
padding-right: 10px;
}
`)
Toast.info('已宽屏处理')
}
createWidescreenControl({ store, execute })
})
/* ===知乎===end */
/* ===掘金===start */
handlers.set('juejin', function() {
const store = createStore('juejin')
function execute() {
GM_addStyle(`
/* 掘金文章 */
@media screen and (min-width: 1300px) {
.main-container {
max-width: 75vw;
}
.main-container .main-area {
width: calc(100% - 21rem);
}
}
`)
Toast.info('已宽屏处理')
}
createWidescreenControl({ store, execute })
})
/* ===掘金===end */
/* ===简书===start */
handlers.set('jianshu', function() {
const store = createStore('jianshu')
function execute() {
GM_addStyle(`
/* 简书文章 */
@media screen and (min-width: 1250px) {
[role=main] > div:first-child {
flex: 1;
width: auto;
}
}
@media screen and (min-width: 1250px) {
[role=main] {
width: 85vw;
}
#__next > div:last-child {
left: 30px;
}
}
@media screen and (min-width: 1450px) {
[role=main] {
width: 75vw;
}
#__next > div:last-child {
left: 7vw;
}
}
`)
Toast.info('已宽屏处理')
}
createWidescreenControl({ store, execute })
})
/* ===简书===end */
/* ===贴吧===start */
handlers.set('tieba', function() {
const store = createStore('tieba')
function execute() {
GM_addStyle(`
/* 帖子 */
@media screen and (min-width: 1390px) {
#container {
width: 70vw;
}
#container > .content {
width: 100%;
}
.nav_wrap, .p_thread, .pb_content, .core_title_wrap_bright, .l_post_bright, .core_reply_wrapper, .l_post_bright .core_reply_wrapper, .pb_footer {
width: 100%;
}
/* 内容区域 */
.pb_content {
display: flex;
background-size: 100%;
}
.pb_content::after {
content: none;
}
/* 楼区域 */
.left_section {
flex: 1;
border-right: 2px solid #e4e6eb;
}
/* 楼层 */
.l_post_bright {
display: flex;
}
.l_post_bright .d_post_content_main{
width: auto;
flex: 1;
}
/* 右侧悬浮按钮 */
.tbui_aside_float_bar {
left: auto;
right: 11vw;
margin-left: 0;
}
}
`)
Toast.info('已宽屏处理')
}
createWidescreenControl({ store, execute })
})
/* ===贴吧===end */
/* ===贴吧主页===start */
handlers.set('tiebaMain', function() {
const store = createStore('tiebaMain')
const jQuery = unsafeWindow.jQuery
const $moreforumEl = jQuery('#moreforum')
// 模拟的app版本
const fakeVersion = '11.8.8.0'
// 未登录时删除已有的BDUSS
if (!$moreforumEl.length) {
delete store.BDUSS
return
}
const ui = new Vue({
template: `
<div>
<div style="position:fixed; z-index:500; top:80px; right:150px;">
<button
style="padding:10px; font-size:14px; color:#fff; background:#3385ff; box-shadow:0 1px 6px rgba(0,0,0,.2);"
:disabled="loading"
@click="run"
>
一键签到
</button>
<p style="margin-top:10px; text-align:center;" title="模拟APP签到可以获得与APP相同的经验,比网页签到经验更多,也提供更多功能,但需要BDUSS,重新登录后需要再次输入,请网上搜索获得方法,不勾选则通过网页签到,此时不需要BDUSS">
<input style="vertical-align:text-top;" v-model="isSimulate" type="checkbox" @change="simulateChange" /> 模拟APP
</p>
<p style="text-align:center;" title="下次进入贴吧时自动签到,建议同时勾选模拟APP">
<input style="vertical-align:text-top;" v-model="isComplete" type="checkbox" /> 自动签到
</p>
</div>
<div style="position:fixed; z-index:2; top:200px; right:10px; width:19vw; min-width:280px; box-shadow:0 1px 6px rgba(0,0,0,.2); background:#fafafa; padding:5px;" v-if="likeForums.length">
<button style="display:block; text-align:center; width:100%;" @click="reverseChange">{{isReverse ? '已倒序' : '普通'}}</button>
<ul style="max-height:65vh; overflow-x:hidden;">
<li style="display:flex; border-bottom:1px solid rgba(221, 221, 221, .5);" v-for="item in diaplayForums" :key="item.forum_id">
<span style="width:56px;" :title="item.level_name">{{item.user_level}}级{{item.is_sign ? ' √' : ''}}{{item.sign_bonus_point ? ('+' + item.sign_bonus_point) : ''}}</span>
<a style="flex:1; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;" :href="'/f?kw=' + item.forum_name" :title="item.forum_name" target="_blank">{{item.forum_name}}</a>
<span style="width:80px" :title="'距离升级' + (item.levelup_score - item.user_exp)">{{item.user_exp}}/{{item.levelup_score}}</span>
</li>
</ul>
</div>
</div>
`,
data() {
return {
loading: false,
isSimulate: false,
isReverse: store.is_reverse || false,
likeForums: [],
}
},
computed: {
isComplete: {
get(){
return store.is_complete || false
},
set(val) {
store.is_complete = val
},
},
diaplayForums() {
const { isReverse, likeForums } = this
return isReverse ? Object.freeze([...likeForums].reverse()) : likeForums
},
},
created() {
if (store.is_simulate && store.BDUSS) {
this.isSimulate = true
}
if (this.isComplete) {
this.run()
}
},
methods:{
run() {
this.loading = true
;(this.isSimulate ? runByBDUSS : runByWeb)(this).finally(() => {
this.loading = false
})
},
simulateChange({ target: { checked } }) {
store.is_simulate = checked
if (!checked) return
const { BDUSS } = store
const result = window.prompt("请输入F12->Application->Cookies中的BDUSS", BDUSS ? BDUSS : undefined)
if (result) {
store.BDUSS = result
} else {
this.$nextTick(() => {
this.isSimulate = false
store.is_simulate = false
})
}
},
reverseChange() {
this.isReverse = !this.isReverse
store.is_reverse = this.isReverse
},
setLikeForums(forums) {
this.likeForums = Object.freeze([...forums])
},
updateLikeForum(fid, forum) {
const { likeForums } = this
const index = likeForums.findIndex(item => +fid === +item.forum_id)
if (index === -1) return
const target = {
...likeForums[index],
...forum,
is_sign: true,
}
if (forum.sign_bonus_point) {
target.user_exp = Number(target.user_exp) + Number(forum.sign_bonus_point)
}
const ectype = [...likeForums]
ectype.splice(index, 1, target)
this.likeForums = Object.freeze(ectype)
},
// 未签到的靠前
checkUnsign() {
const ectype = [...this.likeForums]
ectype.sort((a, b) => {
if (!a.is_sign && b.is_sign) return -1
return 0
})
this.likeForums = Object.freeze(ectype)
},
},
}).$mount()
document.body.appendChild(ui.$el)
// 模拟APP参数
function makeFakeParams(obj) {
return Object.assign({
// 以下可选参数 为了模拟更加真实
_client_type: 4, // 不要更改
_client_version: fakeVersion,
_phone_imei: '0'.repeat(15),
model: 'HUAWEI P40', // HUAWEI加油 ヾ(◍°∇°◍)ノ゙
net_type: 1,
stErrorNums: 1,
stMethod: 1,
stMode: 1,
stSize: 320,
stTime: 117,
stTimesNum: 1,
timestamp: Date.now(),
}, obj)
}
// 贴吧参数签名函数 isFake true时会加入模拟APP参数
function signature(payload, isFake = true) {
if (isFake) {
payload = makeFakeParams(payload)
}
// 提交内容所有name-value按照name的字典序升序排列
const sortKeys = Object.keys(payload).sort()
// 所有内容按照key=value拼接
let str = sortKeys.reduce((acc, key) => (acc += `${key}=${payload[key]}`), '')
// 拼接后补充
str += 'tiebaclient!!!'
// 最后以UTF-8编码进行MD5
return MD5(str)
}
// 界面上无法获得失效的贴吧,这里调用接口获取所有关注的贴吧
async function getLikeForums() {
const { BDUSS } = store
const tbs = unsafeWindow.PageData && unsafeWindow.PageData.tbs
const req2 = makeFakeParams({
BDUSS,
tbs,
})
const [ like1, like2Map ] = await Promise.all([
request.post('/mo/q/newmoindex').then(response => response.json()).then(data => data.data.like_forum),
GMRequest.post('http://c.tieba.baidu.com/c/f/forum/like', utils.URL.stringify({
...req2,
sign: signature(req2),
}), {
headers: {
'User-agent': `bdtb for Android ${fakeVersion}`,
'Accept': '',
'Content-Type': 'application/x-www-form-urlencoded',
'Accept-Encoding': 'gzip',
'Cookie': 'ka=open',
}
}).then(data => data.forum_list).then(forum_list => forum_list.reduce((acc, val) => (acc[val.id] = val, acc), {})),
])
// 融合数据
like1.forEach(forum => {
const { forum_id } = forum
const like2Forum = like2Map[forum_id]
if (!like2Forum) return
Object.assign(forum, {
levelup_score: like2Forum.levelup_score,
level_name: like2Forum.level_name,
slogan: like2Forum.slogan,
})
})
// 经验降序
like1.sort((a, b) => b.user_exp - a.user_exp)
return like1
}
if (store.BDUSS) {
getLikeForums().then(ui.setLikeForums).then(ui.checkUnsign)
}
// 通过BDUSS签到 获得经验与客户端签到相同
async function runByBDUSS(ui) {
// 贴吧必须先触发才能获取剩下贴吧
$moreforumEl.trigger(new MouseEvent('mouseenter'))
// 侧边元素
const likeUnsignEls = $$('#likeforumwraper .unsign')
// 查看更多元素
const alwayUnsignEls = $$('#alwayforum-wraper .unsign')
// 关闭面板
$moreforumEl.trigger(new Event('click'))
const allUnsignEls = [...likeUnsignEls, ...alwayUnsignEls]
// 需要重新签到元素(失败时尝试重签)
const resignEls = []
if (!allUnsignEls.length) {
Toast.success('所有贴吧已经签到')
return
}
const toast = Toast.info({
content: '开始签到,请等待',
duration: 0,
})
// 签到
function doSign(data) {
const { BDUSS } = store
const { tbs, fid, kw } = data
const params = makeFakeParams({
// 以下4个参数 + 下面sign参数 是必选的
BDUSS,
tbs,
fid,
kw,
})
return GMRequest.post('http://c.tieba.baidu.com/c/c/forum/sign', utils.URL.stringify({
...params,
sign: signature(params),
}), {
headers: {
'User-agent': `bdtb for Android ${fakeVersion}`,
'Accept': '',
'Content-Type': 'application/x-www-form-urlencoded',
'Accept-Encoding': 'gzip',
'Cookie': 'ka=open',
}
})
}
const tbs = unsafeWindow.PageData && unsafeWindow.PageData.tbs
while(allUnsignEls.length) {
const current = allUnsignEls.shift()
const { kw } = utils.URL.parse(current.href)
const { fid } = current.dataset
const { error_code, error, error_msg, user_info } = await doSign({ tbs, kw, fid })
// 贴吧成功码为0 还会出现code为0但error的情况
if (error_code === '0' && !error) {
ui.updateLikeForum(fid, user_info)
// 替换已签到样式
current.classList.replace('unsign', 'sign')
} else {
toast.close()
Toast.error(`签到终止,${error_msg || (error ? (error.errmsg || error.usermsg) : '')}`)
// 重签
resignEls.push(current)
}
// 客户端签到可以将延时缩短,垃圾贴吧,随机延时一下 50ms以上
const ms = parseInt(Math.random() * 50 + 50)
await utils.sleep(ms)
}
let failCount = 0
// 重签
while(resignEls.length) {
const current = resignEls.shift()
const { kw } = utils.URL.parse(current.href)
const { fid } = current.dataset
const { error_code, error, user_info } = await doSign({ tbs, kw, fid })
if (error_code === '0' && !error) {
ui.updateLikeForum(fid, user_info)
current.classList.replace('unsign', 'sign')
} else {
failCount++
Toast.error(`${decodeURIComponent(kw)} 签到失败`)
}
await utils.sleep(500)
}
toast.close()
failCount
? Toast.warning({
content: `签到成功,失败${failCount}个`,
duration: 0,
})
: Toast.success('签到成功')
ui.checkUnsign()
}
// 网页签到 经验没客户端那么多 但不需要获得BDUSS只需贴吧已登录即可
async function runByWeb() {
// 贴吧必须先触发才能获取剩下贴吧
$moreforumEl.trigger(new MouseEvent('mouseenter'))
// 侧边元素
const likeUnsignEls = $$('#likeforumwraper .unsign')
// 查看更多元素
const alwayUnsignEls = $$('#alwayforum-wraper .unsign')
// 关闭面板
$moreforumEl.trigger(new Event('click'))
const allUnsignEls = [...likeUnsignEls, ...alwayUnsignEls]
// 需要重新签到元素(失败时尝试重签)
const resignEls = []
if (!allUnsignEls.length) {
Toast.success('所有贴吧已经签到')
return
}
const toast = Toast.info({
content: '开始签到,请等待',
duration: 0,
})
// 签到
function doSign(data) {
return request.post('/sign/add', {
ie: 'utf-8',
...data,
}, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
}
}).then(response => response.json())
}
while(allUnsignEls.length) {
const current = allUnsignEls.shift()
const { kw } = utils.URL.parse(current.href)
const { no } = await doSign({ kw })
// 贴吧成功码为0
if (no === 0) {
// 替换已签到样式
current.classList.replace('unsign', 'sign')
} else {
// 重签
resignEls.push(current)
}
// 网页签到不能太短,否则很容易出现验证码(ಥ﹏ಥ) 验证码2150040
const ms = parseInt(Math.random() * 500 + 500)
await utils.sleep(ms)
}
let failCount = 0
// 重签
while(resignEls.length) {
const current = resignEls.shift()
const { kw } = utils.URL.parse(current.href)
const { no } = await doSign({ kw })
if (no === 0) {
current.classList.replace('unsign', 'sign')
} else {
failCount++
Toast.error(`${decodeURIComponent(kw)} 签到失败`)
}
await utils.sleep(500)
}
toast.close()
failCount
? Toast.warning({
content: `签到成功,失败${failCount}个`,
duration: 0,
})
: Toast.success('签到成功')
}
})
/* ===贴吧主页===end */
// GM请求
function GMRequest(url, options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
...options,
url,
onload(res) {
try {
resolve(JSON.parse(res.response))
} catch (e) {
resolve(res.response)
}
},
onerror: reject,
});
})
}
GMRequest.post = function(url, data, options) {
return GMRequest(url, {
...options,
data,
method: 'POST',
})
}
// 请求
function request(url, options) {
return fetch(url, {
...options,
})
}
request.post = function(url, data, options = {}) {
options.headers = Object.assign({}, options.headers)
if (data) {
let body = data
if (options.headers['Content-Type'].includes('application/x-www-form-urlencoded') && Object.prototype.toString.call(data) === '[object Object]') {
body = utils.URL.stringify(data)
}
if (options.headers['Content-Type'].includes('application/json') && Object.prototype.toString.call(data) === '[object Object]') {
body = JSON.stringify(data)
}
options.body = body
}
return request(url, {
...options,
method: 'POST',
})
}
// 存储 以网站作为模块
function createStore(sitename) {
if (!sitename) throw new TypeError('缺少sitename,期望<string>')
const getRealProp = property => `${sitename}_${property}`
const target = {}
const handler = {
get(target, property) {
const realProp = getRealProp(property)
let value = target[realProp]
if (value == null) {
value = GM_getValue(realProp)
target[realProp] = value
}
return value
},
set(target, property, value) {
const realProp = getRealProp(property)
target[realProp] = value
GM_setValue(realProp, value)
return true
},
deleteProperty(target, property) {
const realProp = getRealProp(property)
const deleted = delete target[realProp]
GM_deleteValue(realProp)
return deleted
},
}
const store = new Proxy(target, handler)
return store
}
// 工具
const utils = {
// url解析
URL: {
parse() {},
stringify() {},
},
// 转formdata
toFormData() {},
// 延时
async sleep() {},
}
utils.URL.parse = function(string) {
const url = new URL(string)
const searchParams = new URLSearchParams(url.search)
return [...searchParams.entries()].reduce((acc, [key, value]) => (acc[key] = value, acc), {})
}
utils.URL.stringify = function(obj) {
return Object.entries(obj).map(([key, value]) => `${key}=${value}`).join('&')
}
utils.toFormData = function(params = {}) {
const formData = new FormData()
for (const [key, value] of Object.entries(params)) {
formData.append(key, value)
}
return formData
}
utils.sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
// Toast 可以直接Toast[type] 调用
function Toast(options) {
if (typeof options === 'string') {
options = { content: options }
}
// 参数
options = Object.assign({
content: '',
type: 'info',
duration: 3000, // 0不会自动关闭
}, options)
const toast = new Vue({
template: `
<transition name="fade" appear @before-enter="beforeEnter" @enter="enter" @leave="leave" @after-leave="afterLeave">
<div
:style="colour"
style="position:fixed; z-index:99999; top:80px; left:50%; padding:8px 16px; font-size:14px; box-shadow:0 2px 3px rgba(0,0,0,.1); transition:all .3s ease-in-out;"
v-if="visible"
v-html="content"
>
</div>
</transition>
`,
data() {
return {
content: options.content,
type: options.type,
visible: true,
}
},
computed: {
// 颜色
colour() {
switch(this.type) {
case 'info':
return {
color: '#2e8bf0',
background: '#f0faff',
border: '1px solid #d4eeff',
}
case 'success':
return {
color: '#19bf6c',
background: '#edfff3',
border: '1px solid #bbf2cf',
}
case 'warning':
return {
color: '#f90',
background: '#fff9e6',
border: '1px solid #ffe7a3',
}
case 'error':
return {
color: '#ed3f13',
background: '#ffefe6',
border: '1px solid #ffcfb8',
}
}
}
},
methods: {
// export-api
// 关闭
close() {
this.visible = false
},
beforeEnter(el) {
el.style.opacity = 0
el.style.transform = 'translate(-50%, -10%)'
},
enter(el, done) {
setTimeout(() => {
el.style.opacity = 1
el.style.transform = 'translate(-50%, 0)'
})
},
leave(el, done) {
setTimeout(() => {
el.style.opacity = 0
el.style.transform = 'translate(-50%, 30%)'
})
},
afterLeave () {
this.$destroy()
this.$el.parentNode.removeChild(this.$el)
},
},
}).$mount()
document.body.appendChild(toast.$el)
if (options.duration > 0) {
setTimeout(() => {
toast.visible = false
}, options.duration)
}
return {
// 关闭
close: toast.close,
}
}
['info', 'success', 'warning', 'error'].forEach(type => {
Toast[type] = function(options) {
if (typeof options === 'string') {
options = { content: options }
}
options = {
...options,
type,
}
return Toast(options)
}
})
// 宽屏开关 options: store<store>, execute要执行的函数
function createWidescreenControl(options) {
const { store, execute } = options
const buttonComponent = new Vue({
template: `
<button
style="position:fixed; z-index:99; top:150px; right:150px; border:none; color:#fff; padding:6px 12px; font-size:14px; background:#3385ff; box-shadow:0 1px 6px rgba(0,0,0,.2);"
title="注意:页面会被刷新"
@click="toggle"
>
{{ isOpen ? '已开启' : '关闭' }}
</button>
`,
data() {
return {
isOpen: store.is_open || false,
}
},
beforeCreate() {
store.is_open && execute()
},
methods: {
async toggle() {
store.is_open = !this.isOpen
location.reload()
}
},
}).$mount()
document.body.appendChild(buttonComponent.$el)
}
main()
})();