// ==UserScript== // @name 豆瓣一键添游戏 | One-Click Add Game to Douban // @namespace https://github.com/lzblack // @homepageURL https://github.com/lzblack/userscripts // @supportURL https://github.com/lzblack/userscripts/issues // @version 1.0.0 // @author lzblack // @description 在 Steam 商店页查豆瓣是否已收录该游戏;未收录则一键跳转「创建游戏条目」、自动回填全字段并注入封面。人工只审核和提交。 // @match https://store.steampowered.com/app/* // @match https://www.douban.com/game/create* // @connect store.steampowered.com // @connect www.douban.com // @connect shared.akamai.steamstatic.com // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // @grant GM_openInTab // @icon https://img3.doubanio.com/favicon.ico // @icon64 https://img3.doubanio.com/favicon.ico // @license MIT // @updateURL https://raw.githubusercontent.com/lzblack/userscripts/main/douban-add-game/douban-add-game.user.js // @downloadURL https://raw.githubusercontent.com/lzblack/userscripts/main/douban-add-game/douban-add-game.user.js // ==/UserScript== (function () { 'use strict'; /** null/undefined 安全的 String()——全脚本统一用它收口空值。 */ const str = (v) => String(v == null ? '' : v); // ============================================================ // 纯函数解析层 — 无 DOM/网络副作用(见 douban-add-game.test.js) // ============================================================ const MONTHS = { jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6, jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12, }; /** * 建条目页(/game/create 第二步)的「类型」全表,共 19 项。 * 比 /game/explore 筛选器上的 15 项多出 卡牌 / 大型多人在线 / 光枪射击 / 文字冒险, * 所以 explore 的那套 id 对建条目页不作数——回填一律按标签文本匹配复选框。 */ const DOUBAN_GENRES = [ '动作', '策略', '体育', '冒险', '角色扮演', '竞速', '模拟', '格斗', '射击', '即时战略', '卡牌', '大型多人在线', '益智', '音乐/旋律', '第一人称射击', '光枪射击', '文字冒险', '乱斗/清版', '横版过关', ]; /** * Steam genre id → 豆瓣「类型」名。 * 按 **id** 映射而非文案:实测同一 app 的 genre id 跨 l=schinese / l=english 稳定, * 只有 description 变(2358720 两种语言均为 1/25/3)。 * Steam 的 4 休闲 / 23 独立 / 37 免费开玩 / 70 抢先体验在豆瓣没有落点—— * 一律进 unmapped 让人工补,不猜。 */ const GENRE_MAP = { 1: '动作', 2: '策略', 3: '角色扮演', 9: '竞速', 18: '体育', 25: '冒险', 28: '模拟', 29: '大型多人在线', }; /** Steam 只能确知这三个平台;主机平台无从得知,留空由人工补。名称与建条目页复选框一致。 */ const PLATFORM_MAP = [ ['windows', 'PC'], ['mac', 'Mac'], ['linux', 'Linux'], ]; /** * 豆瓣建条目页的字段标签(DOM 执行器按这些文本定位控件),取自登录态实拍。 * 第一步「基本资料」只收一个名字然后由豆瓣自己查重;第二步「详细资料」就是下面这些。 * 要点:名称(原文)与中文名称是**两个分开的字段**,没有「别名」;日期只有一个 * 「发售时间」(提示语写明未发售就填预计时间,不像条目展示页分成 * 「发行日期 / 预计上市时间」两栏);图标上传也在这一页,封面无需跨页交接。 */ const FIELD = { titleOriginal: '名称', title: '中文名称', description: '简介', date: '发售时间', genres: '类型', platforms: '平台', }; /** * Steam 有、但建条目页两步都收不下的字段——只能等条目建成后去条目编辑页补。 * 不放进回填计划,改为单独列给用户,免得「已填」列表骗人。 */ const MANUAL_FIELD = [ ['developers', '开发商'], ['publishers', '发行商'], ['website', '官方网站'], ]; /** 从 Steam 路径取 appid;年龄门页 /agecheck/app/{id}/ 同样命中,故无需特判。 */ function parseAppId(pathname) { const m = str(pathname).match(/\/app\/(\d+)/); return m ? Number(m[1]) : null; } /** 解析 "Sep 17, 2020" / "Aug 2024" / "2026" → {y,m,d}(缺位为 null);无年份返回 null。 */ function parseDate(input) { const s = str(input).trim(); const ym = s.match(/([A-Za-z]{3,})[^0-9A-Za-z]+(?:(\d{1,2})[^0-9A-Za-z]+)?(\d{4})/); if (ym) { const mon = MONTHS[ym[1].slice(0, 3).toLowerCase()]; if (mon) { return { y: Number(ym[3]), m: mon, d: ym[2] ? Number(ym[2]) : null }; } } const yOnly = s.match(/\b(\d{4})\b/); if (yOnly) return { y: Number(yOnly[1]), m: null, d: null }; return null; } const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' }; /** 解 HTML 实体(数字型 + 常见命名型);& 最后解,避免二次解码。 */ function decodeEntities(input) { return str(input) .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16))) .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d))) .replace(/&(lt|gt|quot|apos|nbsp);/g, (_, n) => ENTITIES[n]) .replace(/&/g, '&'); } const DROP_TAGS_RE = /<(script|style|video|audio|iframe|noscript)\b[\s\S]*?<\/\1\s*>/gi; const BLOCK_TAGS_RE = /<\/?(p|div|h[1-6]|ul|ol|table|tr|blockquote|section)\b[^>]*>/gi; /** * Steam 的 about_the_game / short_description 是富文本 HTML(bb_tag 小标题、 * bb_ul 列表、内嵌 video/img)。这里做纯正则转纯文本——刻意不用 DOMParser, * 好让这一层能在 node 里单测。 */ function htmlToText(input) { let s = str(input); if (!s) return ''; s = s.replace(DROP_TAGS_RE, ''); s = s.replace(//gi, '\n'); s = s.replace(/]*>/gi, '\n· ').replace(/<\/li\s*>/gi, '\n'); s = s.replace(BLOCK_TAGS_RE, '\n'); s = s.replace(/<[^>]*>/g, ''); // 余下的 inline 标签(strong/span/a/img…)直接摘掉 s = decodeEntities(s); return s .replace(/[^\S\n]+/g, ' ') .split('\n').map((l) => l.trim()).join('\n') .replace(/\n{3,}/g, '\n\n') .trim(); } /** 拉丁路归一(&→and、小写、只留 a-z0-9);中文字符会被剥掉,故只用于英文名比对。 */ function normalizeLatin(input) { return str(input).replace(/&/g, 'and').toLowerCase().replace(/[^a-z0-9]/g, ''); } /** 中文路归一(只留 CJK 统一表意文字)。 */ function normalizeCjk(input) { return str(input).replace(/[^一-鿿]/g, ''); } const HAS_CJK_RE = /[一-鿿]/; /** * 剥掉整对包住标题的书名号——Steam 的中文商店名有时自带(实测 2584270 的 * schinese name 就是「《致命躯壳 II》」),而豆瓣条目名一律不带。 * 只在《》正好包住整串且中间没有别的》时才剥,免得把「《一》与《二》」削成「一》与《二」。 */ function stripOuterBrackets(input) { const s = str(input); return /^《[^》]*》$/.test(s) ? s.slice(1, -1).trim() : s; } /** * 取标题里的「英文段」:按空白切开后丢掉含 CJK 的词块。 * 豆瓣把序号写在中文名上(「哈迪斯2 Hades II」),直接整串归一会得到 * '2hadesii',和英文名 'hadesii' 对不上——而续作常常没有中文商店名, * 中文那路也救不回来。整串都含 CJK(如没有空格分隔的「哈迪斯Hades」)时 * 退回原串,保持旧行为。 */ function latinSegment(input) { const s = str(input); const kept = s.split(/\s+/).filter((t) => t && !HAS_CJK_RE.test(t)).join(' '); return kept || s; } /** * 豆瓣条目标题(形如「哈迪斯 Hades」)与 payload 是否同一款游戏。 * 两路各自归一后精确相等即命中;归一后为空的一路不参与比较, * 否则「纯中文条目 × 纯英文 payload」会双双归一成 '' 而误判。 */ function isTitleMatch(candidateTitle, payload) { const p = payload || {}; const cl = normalizeLatin(latinSegment(candidateTitle)); const pl = normalizeLatin(latinSegment(p.titleEn)); if (cl && pl && cl === pl) return true; const cc = normalizeCjk(candidateTitle); const pc = normalizeCjk(p.title); return Boolean(cc && pc && cc === pc); } /** Steam genres → {genres:[豆瓣类型名], unmapped:[Steam 原文案]},按输入顺序去重。 */ function mapGenres(input) { const list = Array.isArray(input) ? input : []; const genres = []; const unmapped = []; for (const g of list) { if (!g) continue; const hit = GENRE_MAP[Number(g.id)]; if (!hit) { const label = str(g.description).trim(); if (label && !unmapped.includes(label)) unmapped.push(label); continue; } if (!genres.includes(hit)) genres.push(hit); } return { genres, unmapped }; } /** Steam platforms 布尔组 → 豆瓣平台名。 */ function mapPlatforms(input) { const p = input || {}; return PLATFORM_MAP.filter(([key]) => p[key]).map(([, name]) => name); } const STEAM_ASSETS = 'https://shared.akamai.steamstatic.com/store_item_assets/steam/apps'; /** * 封面候选,按偏好排序,运行期逐个试到拿得到图为止: * 竖版 library capsule(600×900,形状最接近豆瓣条目封面)→ 1x 版 → header_image。 * * 竖版 URL 不在 appdetails 响应里,只能拼,而且**两种基址都得试**(实测 8 个 * app): * - 按 appid 裸拼 `…/apps/{appid}/`:1091500、2707930 只在这里有竖版; * - 从 header_image 剥出的基址:新 app 多一段哈希(3807750 的 header 在 * `…/apps/3807750/d0e9…/` 下),1145360、2358720 两种基址都有。 * 竖版对未发售/小体量 app 常常压根不存在(3527290、3807750、4380770、3756870 * 两种基址皆 404),因此 header 兜底是必需的,不是保险。 */ function coverCandidates(headerImage, appid) { const header = str(headerImage).trim(); const bases = []; const m = header.match(/^(.*)\/[^/]+\.(?:jpg|png|webp|avif)(?:\?.*)?$/i); if (m) bases.push(m[1]); if (appid) bases.push(`${STEAM_ASSETS}/${appid}`); const out = []; const push = (u) => { if (u && !out.includes(u)) out.push(u); }; for (const name of ['library_600x900_2x.jpg', 'library_600x900.jpg']) { for (const base of bases) push(`${base}/${name}`); } push(header); return out; } /** appdetails 的 type:只处理 'game',DLC / 原声带('music')/ demo 一律不接。 */ function isSupportedType(type) { return str(type) === 'game'; } /** payload 是否在 TTL 窗口内(now - capturedAt < ttl)。 */ function isPayloadFresh(payload, now, ttl) { const at = payload && payload.source && payload.source.capturedAt; if (typeof at !== 'number') return false; return now - at < ttl; } /** * 这份 HTML 是不是一张真的搜索结果页。 * 豆瓣的风控/验证码页同样回 200 且同样没有 .result——若把它当成「零结果」, * 用户会照着建出重复条目。所以要求两个结构标记同时在场;缺任一就宁可报错、 * 退到人工搜索,也不谎称「没搜到」。两个标记在真结果页和真零结果页上都实测存在。 */ function isSearchResultsPage(input) { const html = str(input); return /class="search-result"/.test(html) && /_SEARCH_CONFIG/.test(html); } const RESULT_SPLIT_RE = /
/g; /** * 解析 https://www.douban.com/search?cat=3114&q=… 的结果页。 * 条目 URL 由 `sid: {id}` 重建成裸地址——页面上的 a[href] 是 /link2/ 跳转包装, * 带 query/pos 参数,不适合当稳定标识。 */ function parseGameSearchResults(input) { const html = str(input); if (!html) return []; const chunks = html.split(RESULT_SPLIT_RE).slice(1); const out = []; const seen = new Set(); for (const chunk of chunks) { const sid = chunk.match(/sid:\s*(\d+)/); if (!sid) continue; const id = Number(sid[1]); if (seen.has(id)) continue; const title = chunk.match(/]*\btitle="([^"]*)"/); const rating = chunk.match(/class="rating_nums"[^>]*>\s*([\d.]+)\s*]*>([\s\S]*?)<\/span>/); seen.add(id); out.push({ id, title: decodeEntities(title ? title[1] : '').trim(), url: `https://www.douban.com/game/${id}/`, rating: rating ? rating[1] : null, cast: decodeEntities(cast ? cast[1] : '').replace(/\s+/g, ' ').trim(), }); } return out; } /** * 两份 appdetails 响应(schinese + english)→ canonical payload。 * 中文那份给名字和简介,英文那份只为拿可解析的日期——schinese 返回的是 * "2020 年 9 月 17 日",parseDate 不吃。 */ function buildPayload(opts) { const o = opts || {}; const zh = o.zh || {}; const en = o.en || null; const appid = o.appid || null; const warnings = []; const title = stripOuterBrackets(str(zh.name).trim()); const titleEn = str(en && en.name).trim() || title; const hasChineseName = normalizeCjk(title) !== ''; if (!hasChineseName) warnings.push('缺中文名(豆瓣游戏条目通常用中文名,请人工补)'); const rd = (en && en.release_date) || zh.release_date || {}; const comingSoon = Boolean(rd.coming_soon); // 只信英文日期;没有英文响应时宁缺勿错,不去解析中文格式。 const releaseDate = en && en.release_date ? parseDate(en.release_date.date) : null; if (!releaseDate) warnings.push(comingSoon ? '未定档(上市时间留空)' : '缺发行日期'); const { genres, unmapped } = mapGenres(zh.genres); // about_the_game 是唯一干净的正文。detailed_description 看着更长,实则混了 // 版本/DLC/社群推广样板(2358720 开头是「数字豪华版…兵器:铜云棒」, // 2707930 开头是「Join Our Community!」),故排除。about 可能整段只有视频 // 没有文字(1091500 实测 3665 字 HTML 转出来 0 字),这时才退 short。 const description = htmlToText(zh.about_the_game) || htmlToText(zh.short_description); if (!description) warnings.push('缺简介'); return { appid, title, titleEn, hasChineseName, developers: Array.isArray(zh.developers) ? zh.developers.filter(Boolean) : [], publishers: Array.isArray(zh.publishers) ? zh.publishers.filter(Boolean) : [], releaseDate, comingSoon, genres, unmappedGenres: unmapped, platforms: mapPlatforms(zh.platforms), website: str(zh.website).trim(), description, coverCandidates: coverCandidates(zh.header_image, appid), warnings, source: { name: 'steam', url: str(o.url), capturedAt: o.now }, }; } /** 查重判定:无 ISBN 这类主键,「搜不到」只能是弱信号,故 maybe/none 分开表述。 */ function classifyDedup(payload, items) { const list = Array.isArray(items) ? items : []; const uniq = []; const seen = new Set(); for (const it of list) { if (!it || seen.has(it.id)) continue; seen.add(it.id); uniq.push(it); } const exact = uniq.find((it) => isTitleMatch(it.title, payload)); if (exact) return { kind: 'hit', item: exact, items: uniq }; if (uniq.length) return { kind: 'maybe', items: uniq.slice(0, 3) }; return { kind: 'none', items: [] }; } /** * 纯函数:payload → 豆瓣建条目页的回填计划。字段按「标签文本」标识, * DOM 执行器据此定位控件(与 douban-add-book 同构)。 */ function buildFillPlan(payload) { const p = payload || {}; const texts = []; const textareas = []; const filled = []; const skipped = []; const warnings = [...(Array.isArray(p.warnings) ? p.warnings : [])]; const push = (bucket, label, value) => { if (value) { bucket.push({ label, value }); filled.push(label); } else skipped.push(label); }; push(texts, FIELD.titleOriginal, p.titleEn); // 没有中文名就把「中文名称」留空——不拿英文名去凑,也不臆造翻译。 push(texts, FIELD.title, p.hasChineseName ? p.title : ''); push(textareas, FIELD.description, p.description); const manual = []; for (const [key, label] of MANUAL_FIELD) { const v = Array.isArray(p[key]) ? p[key].join(' / ') : str(p[key]); if (v) manual.push({ label, value: v }); } const genres = [...(p.genres || [])]; if (genres.length) filled.push(FIELD.genres); else skipped.push(FIELD.genres); for (const label of p.unmappedGenres || []) { warnings.push(`类型「${label}」豆瓣无对应,请人工补选`); } const platforms = [...(p.platforms || [])]; if (platforms.length) filled.push(FIELD.platforms); else skipped.push(FIELD.platforms); const d = p.releaseDate || {}; const date = { field: FIELD.date, y: d.y || null, m: d.m || null, d: d.d || null }; if (date.y) filled.push(FIELD.date); else skipped.push(FIELD.date); if (p.comingSoon && date.y) warnings.push('尚未发售,填的是 Steam 上的预计发售时间'); if (!payload) warnings.push('无有效 payload'); return { texts, textareas, genres, platforms, date, manual, warnings, filled, skipped }; } /** 标签归一:去掉全部空白、尾随冒号、必填标记,再做精确比较。 */ function normLabel(input) { return str(input).replace(/\s+/g, '').replace(/[**]/g, '').replace(/[::]$/, ''); } const FIELD_LABELS = Object.values(FIELD).concat(['图标']); /** * 把线性化的表单 token 流按「最近的前驱字段标签」归组。 * * 刻意不写 `.item > label` 这类结构选择器:豆瓣建条目页的 HTML 没到手, * 而这个算法对 table / div / dl 任何一种排版都成立——只要标签在控件前面出现过。 * 只有 FIELD_LABELS 里的标签会开新组,所以提示语、复选框自带的「PC」「动作」、 * 以及日期后面的「年/月/日」都只是过路 token,不会截断分组。 * * token: {kind:'label', text} | {kind:'control', control, text} * * 控件的名字(复选框旁边的「PC」「动作」)只认 token 自带的 text,**不猜**。 * 曾想过「没带文字就退回上一个标签」,但那不可靠: * [平台][必填,该游戏可运行的平台][□] ← 上一个标签是提示语 * [平台][PC][□] ← 上一个标签是选项名 * 两者在 token 层完全同形,猜错就会把提示语当成选项名去匹配。名字改由 DOM 侧 * 的 controlText() 从 label 结构里取(包住的 label、`label[for]`、后继兄弟节点 * 都覆盖);真取不到就让它匹配不上,如实报进「没找到控件」。 */ function groupControls(tokens) { const list = Array.isArray(tokens) ? tokens : []; const wanted = new Set(FIELD_LABELS.map(normLabel)); const groups = {}; let current = null; list.forEach((t, index) => { if (!t) return; if (t.kind === 'label') { const norm = normLabel(t.text); if (wanted.has(norm)) { current = norm; if (!groups[current]) groups[current] = []; } return; } if (t.kind !== 'control' || !current) return; // 字段标签之前的游离控件不归任何组 groups[current].push({ index, control: t.control, text: str(t.text).trim(), el: t.el }); }); const missing = FIELD_LABELS.filter((l) => !groups[normLabel(l)]); return { groups, missing }; } /** * 当前停在建条目流程的哪一步。两步共用同一个 URL * (都是 https://www.douban.com/game/create),只能靠页面内容判断。 */ function detectStep(groups) { const g = groups || {}; if (g[FIELD.genres] || g[FIELD.platforms]) return 'detail'; if (g[FIELD.titleOriginal]) return 'basic'; return 'unknown'; } // ── node 测试导出(在 DOM 启动代码之前 return) ────────────────────────── if (typeof module !== 'undefined' && module.exports) { module.exports = { parseAppId, parseDate, htmlToText, decodeEntities, normalizeLatin, normalizeCjk, latinSegment, stripOuterBrackets, isTitleMatch, isSearchResultsPage, mapGenres, mapPlatforms, coverCandidates, isSupportedType, isPayloadFresh, parseGameSearchResults, buildPayload, classifyDedup, buildFillPlan, normLabel, groupControls, detectStep, GENRE_MAP, PLATFORM_MAP, DOUBAN_GENRES, FIELD, }; return; } // ============================================================ // 运行期:配置 + GM 封装 // ============================================================ const STORAGE_KEY = 'dag:pending'; const COVER_KEY = 'dag:cover'; const TTL_MS = 10 * 60 * 1000; const CREATE_BASE = 'https://www.douban.com/game/create'; const SEARCH_BASE = 'https://www.douban.com/search?cat=3114&q='; const HIGHLIGHT_SHADOW = '0 0 0 3px rgba(46,125,50,.6)'; // 绿色「该点这个」描边 const deps = { // 默认带 cookie:跨域目标是豆瓣,登录态既是 /game/create 的前提, // 也是对抗搜索接口风控的主要手段。匿名请求请显式传 anonymous:true。 request(url, opts = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: opts.method || 'GET', url, headers: opts.headers || {}, timeout: opts.timeout || 15000, responseType: opts.responseType || undefined, anonymous: opts.anonymous === true, onload: resolve, onerror: () => reject(new Error('request failed: ' + url)), ontimeout: () => reject(new Error('request timeout: ' + url)), }); }); }, }; function escapeHtml(input) { return str(input) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } function safeLinkUrl(input) { const url = str(input).trim(); return /^https?:\/\//i.test(url) ? url : '#'; } // ============================================================ // Steam 适配器:appdetails API → canonical payload(完全不刮 DOM) // ============================================================ /** 取一份 appdetails;下架/区域封锁/非游戏条目(success:false)返回 null。 */ async function fetchAppDetails(appid, lang) { try { const resp = await deps.request( `https://store.steampowered.com/api/appdetails?appids=${appid}&l=${lang}` ); const json = JSON.parse(resp.responseText); const entry = json && json[String(appid)]; return entry && entry.success ? entry.data : null; } catch { return null; } } // ============================================================ // 豆瓣查重服务(与来源无关) // ============================================================ /** 单次搜索;风控/网络失败抛出,由 dedup 统一收成 error 态。 */ async function searchDouban(query) { const resp = await deps.request(SEARCH_BASE + encodeURIComponent(query)); if (resp.status !== 200) throw new Error('douban search status ' + resp.status); if (!isSearchResultsPage(resp.responseText)) throw new Error('douban search not a results page'); return parseGameSearchResults(resp.responseText); } /** * 先按英文名查;命中就收工,没命中才补一次中文名查询。 * 豆瓣搜索有频率限制(连续请求实测会整页 403),所以常见的「已收录」路径 * 只花一次请求。任一次失败即整体 error——绝不把风控当成「没搜到」。 */ async function dedup(payload) { const queries = [...new Set([payload.titleEn, payload.title].filter(Boolean))]; let items = []; let result = { kind: 'none', items: [] }; try { for (const q of queries) { items = items.concat(await searchDouban(q)); result = classifyDedup(payload, items); if (result.kind === 'hit') return result; } } catch { return { kind: 'error' }; } return result; } // ============================================================ // 跨域交接 // ============================================================ function stashAndOpen(payload) { GM_setValue(STORAGE_KEY, JSON.stringify(payload)); // 送原文名:第一步问的是「游戏名称(原文)」,中文名在第二步另有一格。 GM_openInTab(`${CREATE_BASE}?thing_name=${encodeURIComponent(payload.titleEn)}`, { active: true, insert: true, }); } // ============================================================ // Steam 侧角标 UI // ============================================================ const BADGE_ID = 'dag-badge'; const DETAIL_ID = 'dag-detail'; const TOGGLE_ID = 'dag-toggle'; /** * 配色取自 Steam 商店页自身的面板(深色底),而不是豆瓣侧那张米色卡片—— * 米色卡片贴在 #1b2838 的页面上过于突兀。两侧共享的是版式与那颗绿色行动按钮, * 不是底色。 */ const C = { bg: '#1b2838', border: 'rgba(255,255,255,.12)', text: '#c6d4df', muted: '#8f98a0', link: '#66c0f4', warn: '#e3b341', ok: '#a4d007', bad: '#ff7b72', btn: '#388e3c', }; const LINK_STYLE = `color:${C.link};text-decoration:none`; const CELL = 'display:flex;gap:8px;min-width:0'; const KEY = `flex:0 0 52px;color:${C.muted}`; /** 展开区的字段表:两列六格,比原先九行竖排省一半高度。 */ function fieldGridHtml(p) { const date = p.releaseDate ? [p.releaseDate.y, p.releaseDate.m, p.releaseDate.d].filter(Boolean).join('-') : '—'; const cells = [ ['名称', p.titleEn], ['类型', p.genres.join(' / ') || '(无对应)'], ['中文名', p.hasChineseName ? p.title : '(缺)'], ['平台', p.platforms.join(' / ') || '—'], [p.comingSoon ? '预计' : '发行', date], ['简介', p.description ? `${p.description.length} 字` : '(缺失)'], ]; return ( `
` + cells .map(([k, v]) => `
${k}${escapeHtml(String(v))}
`) .join('') + '
' ); } /** 建条目页收不下的字段:单独一行提示,别混进「已填」的错觉里。 */ function manualHtml(plan) { if (!plan.manual.length) return ''; const list = plan.manual.map((m) => `${escapeHtml(m.label)} ${escapeHtml(m.value)}`).join(' · '); return `
建条目页无此字段,创建后到条目编辑页补:${list}
`; } function ensureBadge() { let box = document.getElementById(BADGE_ID); if (box) return box; box = document.createElement('div'); box.id = BADGE_ID; box.style.cssText = `margin:10px 0;padding:8px 12px;border:1px solid ${C.border};border-radius:4px;` + `background:${C.bg};font-size:13px;line-height:1.6;color:${C.text};max-width:640px`; // 主锚点:塞进标题条容器内部(实测落在 top≈506px 首屏内,且 x 与正文栏对齐; // 插到它后面会落进全宽的 .page_top_area,左边缘顶到 x=0 跟页面栅格错开)。 // 左侧正文栏是次选——它自己就在 1165px 处,要滚动才看得到。 const titleArea = document.querySelector('.page_title_area'); const leftcol = document.querySelector('.leftcol.game_description_column'); if (titleArea) { titleArea.appendChild(box); } else if (leftcol) { leftcol.insertBefore(box, leftcol.firstChild); } else { box.style.cssText += ';position:fixed;top:12px;right:12px;z-index:99999;box-shadow:0 2px 12px rgba(0,0,0,.4)'; document.body.appendChild(box); } return box; } function itemLink(it) { const rating = it.rating ? ` ${escapeHtml(it.rating)}` : ''; return `${escapeHtml(it.title)}${rating}`; } /** 首行:结论 + 证据链接。相近条目属于决策依据,必须常驻,不能藏进「详情」。 */ function headHtml(state, payload, result) { const tag = (color, text) => `${text}`; if (state === 'loading') return `豆瓣 · 查重中…`; if (state === 'error') { const q = encodeURIComponent(payload ? payload.titleEn : ''); return `${tag(C.bad, '豆瓣 · 查重失败')} 手动搜索 →`; } if (state === 'hit') return `${tag(C.ok, '豆瓣 · 已收录')} ${itemLink(result.item)}`; if (state === 'maybe') { return `${tag(C.warn, '豆瓣 · 名字相近')} ${result.items.map(itemLink).join(' ')}`; } return `${tag(C.text, '豆瓣 · 没搜到')}(搜不到不等于一定没有)`; } function renderBadge(state, payload, result) { const box = ensureBadge(); const addable = state === 'maybe' || state === 'none'; const action = addable ? `` : ''; const toggle = payload ? `` : ''; // 只在真要添加时列告警——已收录的条目不用管抓取缺了什么。 const plan = payload && addable ? buildFillPlan(payload) : null; const warn = plan && plan.warnings.length ? `
${plan.warnings.map((w) => `⚠ ${escapeHtml(w)}`).join(' ')}
` : ''; const detail = payload ? `` : ''; box.innerHTML = `
` + `
${headHtml(state, payload, result)}
${action}${toggle}
${detail}`; const btn = box.querySelector('#dag-add'); if (btn) btn.addEventListener('click', () => stashAndOpen(payload)); const tog = box.querySelector('#' + TOGGLE_ID); const det = box.querySelector('#' + DETAIL_ID); if (tog && det) { tog.addEventListener('click', () => { const open = det.style.display === 'none'; det.style.display = open ? 'block' : 'none'; tog.textContent = open ? '详情 ▴' : '详情 ▾'; }); } } async function runSteam() { const appid = parseAppId(location.pathname); if (!appid) return; const [zh, en] = await Promise.all([ fetchAppDetails(appid, 'schinese'), fetchAppDetails(appid, 'english'), ]); if (!zh) return; // 下架 / 区域封锁:静默早退 if (!isSupportedType(zh.type)) return; // DLC / 原声带 / demo:静默早退 const payload = buildPayload({ zh, en, appid, url: `https://store.steampowered.com/app/${appid}/`, now: Date.now(), }); renderBadge('loading', payload, null); const result = await dedup(payload); renderBadge(result.kind, payload, result); } // ============================================================ // 分派 // ============================================================ // ============================================================ // 豆瓣侧回填器(与来源无关) // ============================================================ const BANNER_ID = 'dag-banner'; function fireValue(el, value) { el.value = value; el.dispatchEvent(new Event('input', { bubbles: true })); el.dispatchEvent(new Event('change', { bubbles: true })); } function setSelect(sel, value) { const want = String(value); if (![...sel.options].some((o) => o.value === want)) return false; sel.value = want; sel.dispatchEvent(new Event('change', { bubbles: true })); return true; } function controlKind(el) { const tag = el.tagName; if (tag === 'SELECT') return 'select'; if (tag === 'TEXTAREA') return 'textarea'; const type = (el.type || 'text').toLowerCase(); if (type === 'checkbox' || type === 'radio' || type === 'file') return type; if (type === 'hidden' || type === 'submit' || type === 'button') return 'other'; return 'text'; } /** 元素自己的直接文本(不含后代),用来把「标签」与「容器」区分开。 */ function ownText(el) { let out = ''; for (const node of el.childNodes) { if (node.nodeType === 3) out += node.nodeValue; } return out.trim(); } /** * 复选框旁边那个名字。用整段 textContent 而不是 ownText——名字可能被 * 包着(``),只读直接文本会读成空。 * 三种写法都覆盖:label 包住控件、`label[for]` 指过来、控件后面直接跟文字。 */ function controlText(el) { const wrap = el.closest('label'); if (wrap) { const t = wrap.textContent.trim(); if (t) return t; } if (el.id) { const forLabel = document.querySelector(`label[for="${CSS.escape(el.id)}"]`); if (forLabel && forLabel.textContent.trim()) return forLabel.textContent.trim(); } for (let node = el.nextSibling; node; node = node.nextSibling) { const t = (node.textContent || '').trim(); if (t) return t; } return ''; } /** DOM → token 流,交给纯函数 groupControls 归组。 */ function formTokens(root) { const out = []; const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT); for (let el = walker.nextNode(); el; el = walker.nextNode()) { if (/^(INPUT|SELECT|TEXTAREA)$/.test(el.tagName)) { const kind = controlKind(el); // 只有多选组才需要「旁边那行字」;文本框/下拉旁边的字是提示语,读进来反成噪声。 out.push({ kind: 'control', control: kind, text: kind === 'checkbox' || kind === 'radio' ? controlText(el) : '', el }); continue; } const own = ownText(el); if (own) out.push({ kind: 'label', text: own }); } return out; } /** * 页面上不止一个 form(页头搜索框也是)——挑归出字段最多的那个。 * 平手时取**最小**的那个根:嵌套表单和 document.body 会归出同样多的字段, * 但范围越大越容易把别处的控件卷进来。 */ function pickForm() { const roots = [...document.querySelectorAll('form'), document.body]; let best = null; for (const root of roots) { const scan = groupControls(formTokens(root)); const n = Object.keys(scan.groups).length; const size = root.querySelectorAll('*').length; if (!best || n > best.n || (n === best.n && size < best.size)) best = { root, scan, n, size }; } return best; } const firstOf = (list, kind) => (list || []).find((c) => c.control === kind); /** 按名字勾选复选框组;返回真正勾上的名字,便于如实汇报。 */ function checkByName(list, names) { const done = []; for (const name of names) { const hit = (list || []).find((c) => c.control === 'checkbox' && normLabel(c.text) === normLabel(name)); if (!hit || !hit.el) continue; if (!hit.el.checked) { hit.el.checked = true; hit.el.dispatchEvent(new Event('change', { bubbles: true })); } done.push(name); } return done; } /** 「日」下拉常由页面脚本在「月」change 后异步填充,轮询到目标 option 出现为止。 */ function pollSelect(sel, value, attempts) { if (value == null) return Promise.resolve(false); return new Promise((resolve) => { const tick = (n) => { if (setSelect(sel, value)) return resolve(true); if (n <= 0) return resolve(false); setTimeout(() => tick(n - 1), 60); }; tick(attempts); }); } /** 执行回填,返回真正落地的字段——没落地的绝不算进「已填」。 */ const dateSelects = (groups, plan) => (groups[normLabel(plan.date.field)] || []).filter((c) => c.control === 'select'); /** 只负责写。写没写进去不由它说了算——交给 verifyPlan 读回来看。 */ async function applyPlan(groups, plan) { for (const t of plan.texts) { const el = (firstOf(groups[normLabel(t.label)], 'text') || {}).el; if (el) fireValue(el, t.value); } for (const a of plan.textareas) { const el = (firstOf(groups[normLabel(a.label)], 'textarea') || {}).el; if (el) fireValue(el, a.value); } for (const [label, names] of [[FIELD.genres, plan.genres], [FIELD.platforms, plan.platforms]]) { if (names.length) checkByName(groups[normLabel(label)], names); } if (plan.date.y) { const sels = dateSelects(groups, plan); if (sels.length >= 1) { setSelect(sels[0].el, plan.date.y); if (sels.length >= 2 && plan.date.m) { setSelect(sels[1].el, plan.date.m); // 「日」由页面脚本在「月」change 之后异步填充,等它出现再选。 if (sels.length >= 3) await pollSelect(sels[2].el, plan.date.d, 20); } } } } /** * 读回校验:从 DOM 里把值读回来跟计划比。 * 「写过」不等于「填上了」——豆瓣的图标上传会重绘整个表单区,把先前写的值冲掉, * 而横幅在表单外面不受影响,于是会出现「横幅说已填、表单却是空的」。 * 所以汇报一律以读回结果为准。 */ function verifyPlan(groups, plan) { const filled = []; const failed = []; const mark = (label, ok) => (ok ? filled : failed).push(label); const valueOf = (label, kind) => { const el = (firstOf(groups[normLabel(label)], kind) || {}).el; return el ? el.value : null; }; for (const t of plan.texts) mark(t.label, valueOf(t.label, 'text') === t.value); for (const a of plan.textareas) mark(a.label, valueOf(a.label, 'textarea') === a.value); // 多选组可能只勾上一部分:勾上的进「已填」、没勾上的单独列,不含混成一条。 for (const [label, names] of [[FIELD.genres, plan.genres], [FIELD.platforms, plan.platforms]]) { if (!names.length) continue; const list = groups[normLabel(label)] || []; const on = names.filter((n) => list.some((c) => c.control === 'checkbox' && normLabel(c.text) === normLabel(n) && c.el && c.el.checked)); const off = names.filter((n) => !on.includes(n)); if (on.length) filled.push(`${label} ${on.join('/')}`); if (off.length) failed.push(`${label} 缺 ${off.join('/')}`); } if (plan.date.y) { const sels = dateSelects(groups, plan); const want = [plan.date.y, plan.date.m, plan.date.d].filter((v) => v != null); const ok = want.every((v, i) => sels[i] && sels[i].el.value === String(v)); mark(plan.date.field, ok); } return { filled, failed }; } /** 等 DOM 安静下来(连续 quietMs 无变动),最多等 maxMs。 */ function waitForQuiet(target, quietMs, maxMs) { return new Promise((resolve) => { let timer = null; const obs = new MutationObserver(() => { clearTimeout(timer); timer = setTimeout(done, quietMs); }); const done = () => { clearTimeout(timer); clearTimeout(hard); obs.disconnect(); resolve(); }; obs.observe(target, { childList: true, subtree: true, attributes: true }); timer = setTimeout(done, quietMs); const hard = setTimeout(done, maxMs); }); } function injectBanner(root, innerHtml) { const old = document.getElementById(BANNER_ID); if (old) old.remove(); const bar = document.createElement('div'); bar.id = BANNER_ID; // 豆瓣侧沿用与 douban-add-book 同一张米色卡片;换成深色的只是 Steam 那半边。 bar.style.cssText = 'margin:0 0 14px;padding:12px 14px;border:1px solid #d6c79b;border-radius:8px;' + 'background:#fcf9ef;font-size:13px;line-height:1.7;color:#333'; bar.innerHTML = innerHtml; root.parentElement.insertBefore(bar, root); return bar; } function chips(items, color) { return items .map((t) => `${escapeHtml(t)}`) .join(''); } function highlightSubmit(root) { const btn = root.querySelector('input[type="submit"], button[type="submit"]'); if (btn) btn.style.boxShadow = HIGHLIGHT_SHADOW; } /** 逐个试封面候选,第一个拿到真图片就用它。 */ async function fetchCover(candidates) { for (const url of candidates || []) { try { const resp = await deps.request(url, { anonymous: true, responseType: 'blob' }); const blob = resp.response; if (blob && blob.size && /^image\//.test(blob.type || '')) return { blob, url }; } catch { /* 试下一个 */ } } return null; } async function injectCover(fileEl, payload) { const got = await fetchCover(payload.coverCandidates); if (!got) return null; const ext = (got.blob.type.split('/')[1] || 'jpg').replace('jpeg', 'jpg'); const file = new File([got.blob], `cover.${ext}`, { type: got.blob.type }); const dt = new DataTransfer(); dt.items.add(file); fileEl.files = dt.files; fileEl.dispatchEvent(new Event('change', { bubbles: true })); return URL.createObjectURL(got.blob); } function loadPayload() { const raw = GM_getValue(STORAGE_KEY); if (!raw) return null; try { const p = JSON.parse(raw); if (isPayloadFresh(p, Date.now(), TTL_MS)) return p; } catch { /* 损坏即忽略 */ } GM_deleteValue(STORAGE_KEY); return null; } async function fillDetail(root, groups, payload) { // 安全闸门:表单已有的名字与 payload 对不上就只出横幅、不动表单、不消费 payload。 // 这道闸门比添书那边的 ISBN 校验弱(thing_name 是我们自己送过去的), // 但仍挡得住「TTL 窗口内换了一款游戏、手动打开建条目页」这种真实误伤。 const nameEl = (firstOf(groups[normLabel(FIELD.titleOriginal)], 'text') || {}).el; const existing = nameEl ? nameEl.value.trim() : ''; if (existing && !isTitleMatch(existing, payload)) { injectBanner(root, `未自动回填:表单里的名称(${escapeHtml(existing)})与 Steam 抓到的(${escapeHtml(payload.titleEn)})对不上,疑似不是同一款。请手动核对。`); return; } const plan = buildFillPlan(payload); // 封面**先**注入。豆瓣收到图标后会把整个表单区重绘一遍,先填字段的话会被冲掉 // ——实测就是这样:横幅(在表单外)还留着并谎报「已填」,表单里却空空如也。 const fileEl = (firstOf(groups[normLabel('图标')], 'file') || {}).el; let cover = ''; if (fileEl) { const preview = await injectCover(fileEl, payload); cover = preview ? `
封面已注入(来自 Steam),核对后一并提交:
封面预览
` : '
⚠ 封面自动获取失败,请手动上传。
'; if (preview) await waitForQuiet(document.body, 500, 10000); } // 重绘之后原来那批元素引用可能已经作废,必须重扫一遍再填。 // 填完读回校验;若还是没留住(又被重绘了),再来一轮,仍不行就如实报告。 let filled = []; let failed = []; let scope = groups; for (let attempt = 0; attempt < 2; attempt++) { const rescan = pickForm(); scope = rescan ? rescan.scan.groups : groups; await applyPlan(scope, plan); await waitForQuiet(document.body, 300, 3000); const rescan2 = pickForm(); scope = rescan2 ? rescan2.scan.groups : scope; ({ filled, failed } = verifyPlan(scope, plan)); if (!failed.length) break; } const warn = plan.warnings.length ? `
${plan.warnings.map((w) => `⚠ ${escapeHtml(w)}`).join('
')}
` : ''; const manual = plan.manual.length ? `
建条目页没有这些字段,创建成功后请到条目编辑页补:${plan.manual.map((m) => `${escapeHtml(m.label)} ${escapeHtml(m.value)}`).join(' · ')}
` : ''; const bad = failed.length ? `
没填上:${chips(failed, '#f6d6d0')}
` : ''; injectBanner( root, (failed.length ? '豆瓣一键添游戏 · 部分未填上 下面标红的请手动补' : '豆瓣一键添游戏 · 已回填 请核对后人工提交') + `
已填:${chips(filled, '#dbefda')}
` + bad + warn + manual + cover ); highlightSubmit(root); GM_deleteValue(STORAGE_KEY); // 消费即删,免得第二步重载时覆盖你的编辑 } function runDouban() { const payload = loadPayload(); if (!payload) return; // 没有有效 payload:你可能在正常手动建条目,什么都不做 const best = pickForm(); if (!best) return; const step = detectStep(best.scan.groups); if (step === 'detail') { fillDetail(best.root, best.scan.groups, payload); return; } if (step === 'basic') { // 第一步是豆瓣自己的查重闸门——名字已由 thing_name 带过来,剩下的由人点。 // 绝不代点「以上都不是,继续创建」。 injectBanner( best.root, `豆瓣一键添游戏 已带上名称 ${escapeHtml(payload.titleEn)}。` + '这一步是豆瓣自己的查重,请确认列出的都不是同一款,再点「以上都不是,继续创建」——下一步脚本会自动回填全部字段。' ); return; } // 一个字段标签都没认出来:多半是豆瓣改了文案或版式。宁可自报家门, // 也不要装作「还在第一步」——把认出/没认出的都列出来,照着改一处就行。 injectBanner( best.root, '没认出这一步的表单,未做任何回填(数据仍留着,10 分钟内有效)。' + `
认出的字段:${chips(Object.keys(best.scan.groups), '#dbefda') || '(无)'}
` + `
没找到:${chips(best.scan.missing, '#eee')}
` + '
若豆瓣改了字段名,改脚本里的 FIELD 表即可。
' ); } if (location.hostname === 'store.steampowered.com') { runSteam(); } else if (location.hostname === 'www.douban.com') { runDouban(); } })();