diff --git a/admin-frontend/src/App.tsx b/admin-frontend/src/App.tsx index 10ccaf6..6a5063b 100644 --- a/admin-frontend/src/App.tsx +++ b/admin-frontend/src/App.tsx @@ -9,6 +9,7 @@ import SubscribeManager from './features/subsribeConfigManager/SubscribeManager' import WeightConfig from './features/weightConfig/WeightManager'; import Home from './pages/Home'; import Unauthed from './pages/Unauthed'; +import CookieManager from './features/cookieManager/CookieManager'; function App() { const dispatch = useAppDispatch(); @@ -46,6 +47,14 @@ function App() { path: 'weight', element: , }, + { + path: 'cookie', + element: , + }, + { + path: 'cookie/:siteName', + element: , + }, ], }, ], { basename: '/bison' }); diff --git a/admin-frontend/src/app/store.ts b/admin-frontend/src/app/store.ts index abfe3ff..4d52bfd 100644 --- a/admin-frontend/src/app/store.ts +++ b/admin-frontend/src/app/store.ts @@ -17,6 +17,7 @@ import globalConfReducer from '../features/globalConf/globalConfSlice'; import { subscribeApi } from '../features/subsribeConfigManager/subscribeConfigSlice'; import { targetNameApi } from '../features/targetName/targetNameSlice'; import { weightApi } from '../features/weightConfig/weightConfigSlice'; +import { cookieApi, cookieTargetApi } from '../features/cookieManager/cookieConfigSlice'; const rootReducer = combineReducers({ auth: authReducer, @@ -24,6 +25,8 @@ const rootReducer = combineReducers({ [subscribeApi.reducerPath]: subscribeApi.reducer, [weightApi.reducerPath]: weightApi.reducer, [targetNameApi.reducerPath]: targetNameApi.reducer, + [cookieApi.reducerPath]: cookieApi.reducer, + [cookieTargetApi.reducerPath]: cookieTargetApi.reducer, }); const persistConfig = { @@ -43,7 +46,10 @@ export const store = configureStore({ }) .concat(subscribeApi.middleware) .concat(weightApi.middleware) - .concat(targetNameApi.middleware), + .concat(targetNameApi.middleware) + .concat(cookieApi.middleware) + .concat(cookieTargetApi.middleware), + }); export const persistor = persistStore(store); diff --git a/admin-frontend/src/features/cookieManager/CookieAddModal.tsx b/admin-frontend/src/features/cookieManager/CookieAddModal.tsx new file mode 100644 index 0000000..0b0718a --- /dev/null +++ b/admin-frontend/src/features/cookieManager/CookieAddModal.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; +import { Form, Input, Modal } from '@arco-design/web-react'; +import { useNewCookieMutation } from './cookieConfigSlice'; +import { useAppDispatch } from '../../app/hooks'; +import validateCookie from './cookieValidateReq'; + +interface CookieAddModalProps { + visible: boolean; + setVisible: (arg0: boolean) => void; + siteName: string; +} + +function CookieAddModal({ visible, setVisible, siteName }: CookieAddModalProps) { + const FormItem = Form.Item; + const [content, setContent] = useState(''); + const [confirmLoading, setConfirmLoading] = useState(false); + const [newCookie] = useNewCookieMutation(); + const dispatch = useAppDispatch(); + + const onSubmit = () => { + const postPromise: ReturnType = newCookie({ siteName, content }); + setConfirmLoading(true); + postPromise.then(() => { + setConfirmLoading(false); + setVisible(false); + setContent(''); + }); + }; + + return ( + setVisible(false)} + confirmLoading={confirmLoading} + onOk={onSubmit} + style={{ maxWidth: '90vw' }} + > + +
+ + + + new Promise((resolve) => { + dispatch(validateCookie(siteName, value)) + .then((res) => { + if (res) { + callback(); + } else { + callback('Cookie 格式错误'); + } + resolve(); + }); + }), + }, + ]} + + > + + + +
+
+ ); +} + +export default CookieAddModal; diff --git a/admin-frontend/src/features/cookieManager/CookieEditModal.tsx b/admin-frontend/src/features/cookieManager/CookieEditModal.tsx new file mode 100644 index 0000000..3cac931 --- /dev/null +++ b/admin-frontend/src/features/cookieManager/CookieEditModal.tsx @@ -0,0 +1,128 @@ +import React, { useState } from 'react'; +import { + Button, Empty, Form, Input, Modal, Space, Table, +} from '@arco-design/web-react'; +import { useDeleteCookieTargetMutation, useGetCookieTargetsQuery } from './cookieConfigSlice'; +import { Cookie, CookieTarget } from '../../utils/type'; +import CookieTargetModal from '../cookieTargetManager/CookieTargetModal'; + +interface CookieEditModalProps { + visible: boolean; + setVisible: (arg0: boolean) => void; + cookie: Cookie | null +} + +function CookieEditModal({ visible, setVisible, cookie }: CookieEditModalProps) { + if (!cookie) { + return ; + } + const FormItem = Form.Item; + // const [confirmLoading, setConfirmLoading] = useState(false); + const [deleteCookieTarget] = useDeleteCookieTargetMutation(); + // 获取 Cookie Target + const { data: cookieTargets } = useGetCookieTargetsQuery({ cookieId: cookie.id }); + + // 添加 Cookie Target + const [showAddCookieTargetModal, setShowAddCookieTargetModal] = useState(false); + const handleAddCookieTarget = () => () => { + setShowAddCookieTargetModal(true); + }; + + // 删除 Cookie Target + const handleDelete = (record: CookieTarget) => () => { + deleteCookieTarget({ + cookieId: record.cookie_id, + target: record.target.target, + platformName: record.target.platform_name, + }); + }; + const columns = [ + { + title: '平台名称', + dataIndex: 'target.platform_name', + }, + { + title: '订阅名称', + dataIndex: 'target.target_name', + + }, + { + title: 'Cookie ID', + dataIndex: 'cookie_id', + }, + { + title: '操作', + dataIndex: 'op', + render: (_: null, record: CookieTarget) => ( + + + + ), + }, + ]; + + return ( + <> + setVisible(false)} + // confirmLoading={confirmLoading} + onOk={() => setVisible(false)} + style={{ maxWidth: '90vw', minWidth: '50vw' }} + > +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + `${record.target.platform_name}-${record.target.target}`} + scroll={{ x: true }} + /> + + + + + ); +} + +export default CookieEditModal; diff --git a/admin-frontend/src/features/cookieManager/CookieManager.css b/admin-frontend/src/features/cookieManager/CookieManager.css new file mode 100644 index 0000000..584e7fa --- /dev/null +++ b/admin-frontend/src/features/cookieManager/CookieManager.css @@ -0,0 +1,13 @@ +.list-actions-icon { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + transition: all 0.1s; +} + +.list-actions-icon:hover { + background-color: var(--color-fill-3); +} diff --git a/admin-frontend/src/features/cookieManager/CookieManager.tsx b/admin-frontend/src/features/cookieManager/CookieManager.tsx new file mode 100644 index 0000000..5c36769 --- /dev/null +++ b/admin-frontend/src/features/cookieManager/CookieManager.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { + Button, + Table, TableColumnProps, Typography, Space, Popconfirm, +} from '@arco-design/web-react'; +import { useParams } from 'react-router-dom'; +import { useGetCookiesQuery, useDeleteCookieMutation } from './cookieConfigSlice'; +import './CookieManager.css'; +import { Cookie } from '../../utils/type'; +import CookieAddModal from './CookieAddModal'; +import CookieEditModal from './CookieEditModal'; + +export default function CookieManager() { + const { siteName } = useParams(); + const { data: cookieDict } = useGetCookiesQuery(); + const cookiesList = cookieDict ? Object.values(cookieDict) : []; + + // 添加cookie + const [showAddModal, setShowAddModal] = React.useState(false); + const handleAddCookie = () => () => { + setShowAddModal(true); + }; + + // 删除cookie + const [deleteCookie] = useDeleteCookieMutation(); + const handleDelCookie = (cookieId: string) => () => { + deleteCookie({ + cookieId, + }); + }; + + // 编辑cookie + const [showEditModal, setShowEditModal] = React.useState(false); + const [editCookie, setEditCookie] = React.useState(null); + const handleEditCookie = (cookie: Cookie) => () => { + setEditCookie(cookie); + setShowEditModal(true); + }; + + let data = []; + if (siteName) { + data = cookiesList.filter((tSite) => tSite.site_name === siteName); + } + + const columns: TableColumnProps[] = [ + { + title: 'ID', + dataIndex: 'id', + }, + { + title: 'Cookie 名称', + dataIndex: 'cookie_name', + }, + { + title: '所属站点', + dataIndex: 'site_name', + }, + { + title: '最后使用时间', + dataIndex: 'last_usage', + }, + { + title: '状态', + dataIndex: 'status', + }, + { + title: 'CD', + dataIndex: 'cd_milliseconds', + }, { + title: '操作', + dataIndex: 'op', + render: (_: null, record: Cookie) => ( + + + + {/* */} + + + + + + ), + + }, + + ]; + + return ( + <> +
+ + Cookie 管理 + + +
+ +
+ + + + ); +} diff --git a/admin-frontend/src/features/cookieManager/cookieConfigSlice.ts b/admin-frontend/src/features/cookieManager/cookieConfigSlice.ts new file mode 100644 index 0000000..497b270 --- /dev/null +++ b/admin-frontend/src/features/cookieManager/cookieConfigSlice.ts @@ -0,0 +1,66 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; +import { + StatusResp, Cookie, NewCookieParam, + DelCookieParam, CookieTarget, NewCookieTargetParam, DelCookieTargetParam, +} from '../../utils/type'; +import { baseQueryWithAuth } from '../auth/authQuery'; + +export const cookieApi = createApi({ + reducerPath: 'cookie', + baseQuery: baseQueryWithAuth, + tagTypes: ['Cookie'], + endpoints: (builder) => ({ + getCookies: builder.query({ + query: () => '/cookie', + providesTags: ['Cookie'], + }), + newCookie: builder.mutation({ + query: ({ siteName, content }) => ({ + method: 'POST', + url: `/cookie?site_name=${siteName}&content=${content}`, + }), + invalidatesTags: ['Cookie'], + }), + deleteCookie: builder.mutation({ + query: ({ cookieId }) => ({ + method: 'DELETE', + url: `/cookie/${cookieId}`, + }), + invalidatesTags: ['Cookie'], + }), + }), +}); + +export const { + useGetCookiesQuery, useNewCookieMutation, useDeleteCookieMutation, +} = cookieApi; + +export const cookieTargetApi = createApi({ + reducerPath: 'cookieTarget', + baseQuery: baseQueryWithAuth, + tagTypes: ['CookieTarget'], + endpoints: (builder) => ({ + getCookieTargets: builder.query({ + query: ({ cookieId }) => `/cookie_target?cookie_id=${cookieId}`, + providesTags: ['CookieTarget'], + }), + newCookieTarget: builder.mutation({ + query: ({ platformName, target, cookieId }) => ({ + method: 'POST', + url: `/cookie_target?platform_name=${platformName}&target=${encodeURIComponent(target)}&cookie_id=${cookieId}`, + }), + invalidatesTags: ['CookieTarget'], + }), + deleteCookieTarget: builder.mutation({ + query: ({ platformName, target, cookieId }) => ({ + method: 'DELETE', + url: `/cookie_target?platform_name=${platformName}&target=${encodeURIComponent(target)}&cookie_id=${cookieId}`, + }), + invalidatesTags: ['CookieTarget'], + }), + }), +}); + +export const { + useGetCookieTargetsQuery, useNewCookieTargetMutation, useDeleteCookieTargetMutation, +} = cookieTargetApi; diff --git a/admin-frontend/src/features/cookieManager/cookieValidateReq.ts b/admin-frontend/src/features/cookieManager/cookieValidateReq.ts new file mode 100644 index 0000000..6e8e5cb --- /dev/null +++ b/admin-frontend/src/features/cookieManager/cookieValidateReq.ts @@ -0,0 +1,20 @@ +import { AppThunk } from '../../app/store'; +import { baseUrl } from '../../utils/urls'; + +// eslint-disable-next-line +export const validCookie = + (siteName: string, content: string): AppThunk> => async (_, getState) => { + const url = `${baseUrl}cookie/validate?site_name=${siteName}&content=${content}`; + const state = getState(); + const authToken = state.auth.token; + const res = await fetch(url, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + method: 'POST', + }); + const resObj = await res.json(); + return resObj.ok; + }; + +export default validCookie; diff --git a/admin-frontend/src/features/cookieTargetManager/CookieTargetModal.tsx b/admin-frontend/src/features/cookieTargetManager/CookieTargetModal.tsx new file mode 100644 index 0000000..550b921 --- /dev/null +++ b/admin-frontend/src/features/cookieTargetManager/CookieTargetModal.tsx @@ -0,0 +1,109 @@ +import React + from 'react'; +import { + Empty, Form, Modal, Select, +} from '@arco-design/web-react'; +import { Cookie, SubscribeConfig, SubscribeGroupDetail } from '../../utils/type'; +import { useNewCookieTargetMutation } from '../cookieManager/cookieConfigSlice'; +import { useGetSubsQuery } from '../subsribeConfigManager/subscribeConfigSlice'; +import { useAppSelector } from '../../app/hooks'; +import { selectPlatformConf } from '../globalConf/globalConfSlice'; + +interface SubscribeModalProp { + cookie:Cookie| null + visible: boolean; + setVisible: (arg0: boolean) => void; +} + +export default function CookieTargetModal({ + cookie, visible, setVisible, +}: SubscribeModalProp) { + if (!cookie) { + return ; + } + const [newCookieTarget] = useNewCookieTargetMutation(); + const FormItem = Form.Item; + + // 筛选出当前Cookie支持的平台 + const platformConf = useAppSelector(selectPlatformConf); + const platformThatSiteSupport = Object.values(platformConf).reduce((p, c) => { + if (c.siteName in p) { + p[c.siteName].push(c.platformName); + } else { + p[c.siteName] = [c.platformName]; + } + return p; + }, {} as Record); + const supportedPlatform = platformThatSiteSupport[cookie.site_name]; + + const { data: subs } = useGetSubsQuery(); + const pureSubs:SubscribeConfig[] = subs ? Object.values(subs) + .reduce(( + pv:Array, + cv:SubscribeGroupDetail, + ) => pv.concat(cv.subscribes), []) : []; + const filteredSubs = pureSubs.filter((sub) => supportedPlatform.includes(sub.platformName)); + const [index, setIndex] = React.useState(-1); + + const handleSubmit = (idx:number) => { + const postPromise: ReturnType = newCookieTarget({ + cookieId: cookie.id, + platformName: filteredSubs[idx].platformName, + target: filteredSubs[idx].target, + }); + postPromise.then(() => { + setVisible(false); + }); + }; + const { Option } = Select; + + return ( + setVisible(false)} + onOk={() => handleSubmit(index)} + > + +
+ + + + + + + + + + +
+ ); +} diff --git a/admin-frontend/src/features/globalConf/globalConfSlice.ts b/admin-frontend/src/features/globalConf/globalConfSlice.ts index 8c653f6..a9f0d8e 100644 --- a/admin-frontend/src/features/globalConf/globalConfSlice.ts +++ b/admin-frontend/src/features/globalConf/globalConfSlice.ts @@ -6,6 +6,7 @@ import { globalConfUrl } from '../../utils/urls'; const initialState = { loaded: false, platformConf: {}, + siteConf: {}, } as GlobalConf; export const loadGlobalConf = createAsyncThunk( @@ -24,6 +25,7 @@ export const globalConfSlice = createSlice({ builder .addCase(loadGlobalConf.fulfilled, (state, payload) => { state.platformConf = payload.payload.platformConf; + state.siteConf = payload.payload.siteConf; state.loaded = true; }); }, @@ -33,3 +35,4 @@ export default globalConfSlice.reducer; export const selectGlobalConfLoaded = (state: RootState) => state.globalConf.loaded; export const selectPlatformConf = (state: RootState) => state.globalConf.platformConf; +export const selectSiteConf = (state: RootState) => state.globalConf.siteConf; diff --git a/admin-frontend/src/pages/Home.tsx b/admin-frontend/src/pages/Home.tsx index 2c676b4..cc5eb9b 100644 --- a/admin-frontend/src/pages/Home.tsx +++ b/admin-frontend/src/pages/Home.tsx @@ -1,13 +1,15 @@ import React, { ReactNode, useEffect, useState } from 'react'; import { Breadcrumb, Layout, Menu } from '@arco-design/web-react'; -import { IconRobot, IconDashboard } from '@arco-design/web-react/icon'; +import { + IconRobot, IconDashboard, IconIdcard, +} from '@arco-design/web-react/icon'; import './Home.css'; -// import SubscribeManager from '../features/subsribeConfigManager/SubscribeManager'; import { Link, Navigate, Outlet, useLocation, useNavigate, } from 'react-router-dom'; import { useAppSelector } from '../app/hooks'; import { selectIsLogin } from '../features/auth/authSlice'; +import { selectSiteConf } from '../features/globalConf/globalConfSlice'; export default function Home() { const location = useLocation(); @@ -23,6 +25,12 @@ export default function Home() { if (path !== '/home/groups' && !path.startsWith('/home/groups/') && path !== '/home/weight') { navigate('/home/groups'); } + if (path === '/home/cookie') { + navigate('/home/cookie'); + } + if (path.startsWith('/home/cookie/')) { + navigate(path); + } }, [path]); let currentKey = ''; @@ -30,6 +38,8 @@ export default function Home() { currentKey = 'groups'; } else if (path.startsWith('/home/groups/')) { currentKey = 'subs'; + } else if (path.startsWith('/home/cookie/')) { + currentKey = path.substring(6); } const [selectedTab, changeSelectTab] = useState(currentKey); @@ -40,6 +50,10 @@ export default function Home() { navigate('/home/groups'); } else if (tab === 'weight') { navigate('/home/weight'); + } else if (tab === 'cookie') { + navigate('/home/cookie'); + } else if (tab.startsWith('cookie/')) { + navigate(`/home/${tab}`); } }; @@ -80,7 +94,22 @@ export default function Home() { ); + } else if (path.startsWith('/home/cookie')) { + breadcrumbContent = ( + + + + + Cookie 管理 + + + + ); } + const MenuItem = Menu.Item; + const { SubMenu } = Menu; + const siteConf = useAppSelector(selectSiteConf); + return ( @@ -95,12 +124,29 @@ export default function Home() { > { handleTabSelect(key); }} + onClickMenuItem={(key) => { + handleTabSelect(key); + }} > 订阅管理 + + + Cookie 管理 + + )} + > + {Object.values(siteConf).filter((site) => site.enable_cookie).map((site) => ( + + {site.name} + + ))} + 调度权重 @@ -109,7 +155,7 @@ export default function Home() { - { breadcrumbContent } + {breadcrumbContent} diff --git a/admin-frontend/src/utils/type.ts b/admin-frontend/src/utils/type.ts index 6f877f1..16dd409 100644 --- a/admin-frontend/src/utils/type.ts +++ b/admin-frontend/src/utils/type.ts @@ -4,8 +4,10 @@ export interface TokenResp { id: number; name: string; } + export interface GlobalConf { platformConf: AllPlatformConf; + siteConf: AllSiteConf; loaded: boolean; } @@ -13,6 +15,10 @@ export interface AllPlatformConf { [idx: string]: PlatformConfig; } +export interface AllSiteConf { + [idx: string]: SiteConfig; +} + export interface CategoryConfig { [idx: number]: string; } @@ -22,9 +28,15 @@ export interface PlatformConfig { categories: CategoryConfig; enabledTag: boolean; platformName: string; + siteName: string; hasTarget: boolean; } +export interface SiteConfig { + name: string; + enable_cookie: string; +} + export interface SubscribeConfig { platformName: string; target: string; @@ -69,3 +81,48 @@ export interface PlatformWeightConfigResp { platform_name: string; weight: WeightConfig; } + +export interface Target { + platform_name: string; + target_name: string; + target: string; +} + +export interface Cookie { + id: number; + site_name: string; + content: string; + cookie_name: string; + last_usage: Date; + status: string; + cd_milliseconds: number; + is_universal: boolean; + is_anonymous: boolean; + tags: { [key: string]: string }; +} + +export interface CookieTarget { + target: Target; + cookie_id: number; +} + +export interface NewCookieParam { + siteName: string; + content: string; +} + +export interface DelCookieParam { + cookieId: string; +} + +export interface NewCookieTargetParam { + platformName: string; + target: string; + cookieId: number; +} + +export interface DelCookieTargetParam { + platformName: string; + target: string; + cookieId: number; +} diff --git a/docs/.vuepress/navbar.ts b/docs/.vuepress/navbar.ts index b410c61..788435c 100644 --- a/docs/.vuepress/navbar.ts +++ b/docs/.vuepress/navbar.ts @@ -23,11 +23,29 @@ export default navbar([ link: "", activeMatch: "^/usage/?$", }, + { + text: "Cookie 使用", + icon: "cookie", + link: "cookie", + }, ], }, { text: "开发", icon: "flask", - link: "/dev/", + prefix: "/dev/", + children: [ + { + text: "基本开发", + icon: "tools", + link: "", + activeMatch: "^/dev/?$", + }, + { + text: "Cookie 开发", + icon: "cookie", + link: "cookie", + }, + ], }, ]); diff --git a/docs/.vuepress/public/images/add-cookie-2.png b/docs/.vuepress/public/images/add-cookie-2.png new file mode 100644 index 0000000..c670560 Binary files /dev/null and b/docs/.vuepress/public/images/add-cookie-2.png differ diff --git a/docs/.vuepress/public/images/add-cookie-target.png b/docs/.vuepress/public/images/add-cookie-target.png new file mode 100644 index 0000000..4ea5fd3 Binary files /dev/null and b/docs/.vuepress/public/images/add-cookie-target.png differ diff --git a/docs/.vuepress/public/images/add-cookie.png b/docs/.vuepress/public/images/add-cookie.png new file mode 100644 index 0000000..d20565d Binary files /dev/null and b/docs/.vuepress/public/images/add-cookie.png differ diff --git a/docs/.vuepress/theme.ts b/docs/.vuepress/theme.ts index 6676dd0..d3e124f 100644 --- a/docs/.vuepress/theme.ts +++ b/docs/.vuepress/theme.ts @@ -80,6 +80,7 @@ export default hopeTheme({ sup: true, tabs: true, vPre: true, + mermaid: true, // 在启用之前安装 chart.js // chart: true, @@ -101,9 +102,6 @@ export default hopeTheme({ // 在启用之前安装 mathjax-full // mathjax: true, - // 在启用之前安装 mermaid - // mermaid: true, - // playground: { // presets: ["ts", "vue"], // }, diff --git a/docs/dev/README.md b/docs/dev/README.md index 361eecd..77c54a5 100644 --- a/docs/dev/README.md +++ b/docs/dev/README.md @@ -1,3 +1,8 @@ +--- +prev: /usage/install +next: /dev/cookie +--- + # 基本开发须知 ## 语言以及工具 diff --git a/docs/dev/cookie.md b/docs/dev/cookie.md new file mode 100644 index 0000000..93378d6 --- /dev/null +++ b/docs/dev/cookie.md @@ -0,0 +1,157 @@ +--- +prev: /usage/ +#next: /dev/cookie +--- + +# Cookie 开发须知 + +本项目将大部分 Cookie 相关逻辑提出到了 Site 及 ClientManger 模块中,只需要继承相关类即可获得使用 Cookie 的能力。 + +::: tip + +在开发 Cookie 功能之前,你应该对[基本开发](/dev/#基本开发)有一定的了解。 + +::: + +## Cookie 相关的基本概念 + +- `nonebot_bison.config.db_model.Cookie`: 用于存储 Cookie 的实体类,包含了 Cookie 的名称、内容、状态等信息 +- `nonebot_bison.config.db_model.CookieTarget`: 用于存储 Cookie 与订阅的关联关系 +- `nonebot_bison.utils.site.CookieClientManager`: 添加了 Cookie 功能的 ClientManager,是 Cookie 管理功能的核心,调度 Cookie 的功能就在这里实现 + +## 快速上手 + +例如,现在有一个这样子的 Site 类: + +```python +class WeiboSite(Site): + name = "weibo.com" + schedule_type = "interval" + schedule_setting = {"seconds": 3} +``` + +简而言之,要让站点获得 Cookie 能力,只需要: + +为 Site 类添加一个`client_mgr`字段,值为`CookieClientManager.from_name(name)`,其中`name`为站点名称,这是默认的 Cookie 管理器。 + +```python {5} +class WeiboSite(Site): + name = "weibo.com" + schedule_type = "interval" + schedule_setting = {"seconds": 3} + client_mgr = CookieClientManager.from_name(name) +``` + +至此,站点就可以使用 Cookie 了! + +## 更好的体验 + +为了给用户提供更好的体验,还可以创建自己的 `ClientManager`:继承 `CookieClientManager` 并重写`validate_cookie`和`get_target_name`方法。 + +- `async def validate_cookie(cls, content: str) -> bool`该方法将会在 Cookie 添加时被调用,可以在这里验证 Cookie 的有效性 +- `async def get_cookie_name(cls, content: str) -> str`该方法将会在验证 Cookie 成功后被调用,可以在这里设置 Cookie 的名字并展示给用户 + +## 自定义 Cookie 调度策略 + +当默认的 Cookie 调度逻辑无法满足需求时,可以重写`CookieClientManager`的`_choose_cookie`方法。 + +目前整体的调度逻辑是: + +```mermaid +sequenceDiagram + participant Scheduler + participant Platform + participant CookieClientManager + participant DB + participant Internet + + Scheduler->>Platform: exec_fetch + Platform->>Platform: do_fetch_new_post(SubUnit) + Platform->>Platform: get_sub_list(Target) + Platform->>CookieClientManager: get_client(Target) + CookieClientManager->>CookieClientManager: _choose_cookie(Target) + CookieClientManager->>DB: get_cookies() + CookieClientManager->>CookieClientManager: _assemble_client(Target, cookie) + CookieClientManager->>Platform: client + Platform->>Internet: client.get(Target) + Internet->>Platform: response + Platform->>CookieClientManager: _response_hook() + CookieClientManager->>DB: update_cookie() + +``` + +目前 CookieClientManager 具有以下方法 + +- `refresh_anonymous_cookie(cls)` 移除已有的匿名 cookie,添加一个新的匿名 cookie,应该在 CCM 初始化时调用 +- `add_user_cookie(cls, content: str)` 添加用户 cookie,在这里对 Cookie 进行检查并获取 cookie_name,写入数据库 +- `_generate_hook(self, cookie: Cookie) -> Callable` hook 函数生成器,用于回写请求状态到数据库 +- `_choose_cookie(self, target: Target) -> Cookie` 选择 cookie 的具体算法 +- `add_user_cookie(cls, content: str, cookie_name: str | None = None) -> Cookie` 对外的接口,添加用户 cookie,内部会调用 Site 的方法进行检查 +- `get_client(self, target: Target | None) -> AsyncClient` 对外的接口,获取 client,根据 target 选择 cookie +- `_assemble_client(self, client, cookie) -> AsyncClient` 组装 client,可以自定义 cookie 对象的 content 装配到 client 中的方式 + +::: details 大致流程 + +1. `Platfrom` 调用 `CookieClientManager.get_client` 方法,传入 `Target` 对象 +2. `CookieClientManager` 根据 `Target` 选择一个 `Cookie` 对象 +3. `CookieClientManager` 调用 `CookieClientManager._assemble_client` 方法,将 Cookie 装配到 `Client` 中 +4. `Platform` 使用 `Client` 进行请求 + ::: + +简单来说: + +- 如果需要修改 Cookie 的默认参数,可以重写`add_user_cookie`方法,这里设置需要的字段 +- 如果需要修改选择 Cookie 的逻辑,可以重写`_choose_cookie`方法,使用自己的算法选择合适的 Cookie 并返回 +- 如果需要自定义 Cookie 的格式,可以重写`valid_cookie`方法,自定义验证 Cookie 的逻辑,并重写`_assemble_client`方法,自定义将 Cookie 装配到 Client 中的逻辑 +- 如果要在请求结束后做一些操作(例如保存此次请求的结果/状态),可以重写`_response_hook`方法,自定义请求结束后的行为 +- 如果需要跳过一次请求,可以在 `get_client` 方法中抛出 `SkipRequestException` 异常,调度器会捕获该异常并跳过此次请求 + +## 实名 Cookie 和匿名 Cookie + +部分站点所有接口都需要携带 Cookie,对于匿名用户(未登录)也会发放一个临时 Cookie,本项目称为匿名 Cookie。 + +在此基础上,我们添加了用户上传 Cookie 的功能,这种 Cookie 本项目称为实名 Cookie。 + +匿名 Cookie 和实名 Cookie 在同一个框架下统一调度,实名 Cookie 优先级高于匿名 Cookie。为了调度,Cookie 对象有以下字段: + +```python + # 最后使用的时刻 + last_usage: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime(1970, 1, 1)) + # Cookie 当前的状态 + status: Mapped[str] = mapped_column(String(20), default="") + # 使用一次之后,需要的冷却时间 + cd_milliseconds: Mapped[int] = mapped_column(default=0) + # 是否是通用 Cookie(对所有 Target 都有效) + is_universal: Mapped[bool] = mapped_column(default=False) + # 是否是匿名 Cookie + is_anonymous: Mapped[bool] = mapped_column(default=False) + # 标签,扩展用 + tags: Mapped[dict[str, Any]] = mapped_column(JSON().with_variant(JSONB, "postgresql"), default={}) +``` + +其中: + +- **is_universal**:用于标记 Cookie 是否为通用 Cookie,即对所有 Target 都有效。可以理解为是一种特殊的 target,添加 Cookie 和获取 Cookie 时通过传入参数进行设置。 + +- **is_anonymous**:用于标记 Cookie 是否为匿名 Cookie,目前的定义是:可以由程序自动生成的,适用于所有 Target 的 Cookie。目前的逻辑是 bison 启动时,生成一个新的匿名 Cookie 并替换掉原有的匿名 Cookie。 + +- **无 Target 平台的 Cookie 处理方式** + + 对于不存在 Target 的平台,如小刻食堂,可以重写 add_user_cookie 方法,为用户 Cookie 设置 is_universal 字段。这样,在获取 Client 时,由于传入的 Target 为空,就只会选择 is_universal 的 cookie。实现了无 Target 平台的用户 Cookie 调度。 + +## 默认的调度策略 + +默认的调度策略在 CookieClientManager 的 `_choose_cookie` 方法中实现: + +```python + async def _choose_cookie(self, target: Target | None) -> Cookie: + """选择 cookie 的具体算法""" + cookies = await config.get_cookie(self._site_name, target) + cookies = (cookie for cookie in cookies if cookie.last_usage + cookie.cd < datetime.now()) + cookie = min(cookies, key=lambda x: x.last_usage) + return cookie +``` + +简而言之,会选择最近使用时间最早的 Cookie,且不在冷却时间内的 Cookie。 + +在默认情况下,匿名 Cookie 的冷却时间为 0,实名 Cookie 的冷却时间为 10 秒。也就是说,调度时,如果没有可用的实名 Cookie,则会选择匿名 Cookie。 diff --git a/docs/usage/README.md b/docs/usage/README.md index 4a877de..70f126a 100644 --- a/docs/usage/README.md +++ b/docs/usage/README.md @@ -1,6 +1,6 @@ --- prev: /usage/install -next: /usage/easy-use +next: /usage/cookie --- # 全方位了解 Bison 的自行车 @@ -272,6 +272,21 @@ Bison 在处理每条推送时,会按照以下规则顺序检查推送中的 T 3. **需订阅 Tag** 列表为空 - **发送**该推送到群中,检查结束 +#### Cookie 功能 + +Bison 支持携带 Cookie 进行请求。 + +目前支持的平台有: + +- `rss`: RSS +- `weibo`: 新浪微博 + +::: warning 使用须知 +Cookie 全局生效,这意味着,通过你的 Cookie 获取到的内容,可能会被发给其他用户。 +::: + +管理员可以通过**命令**或**管理后台**给 Bison 设置 Cookie。 +