memeseeks 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- memeseeks/__init__.py +3 -0
- memeseeks/__main__.py +3 -0
- memeseeks/albums.py +161 -0
- memeseeks/browser/memeseeks.user.js +658 -0
- memeseeks/cli.py +374 -0
- memeseeks/evalkit.py +134 -0
- memeseeks/images.py +77 -0
- memeseeks/inbox.py +118 -0
- memeseeks/index.py +254 -0
- memeseeks/indexer.py +129 -0
- memeseeks/library.py +176 -0
- memeseeks/maintext.py +197 -0
- memeseeks/masking.py +16 -0
- memeseeks/models/__init__.py +10 -0
- memeseeks/models/clip.py +39 -0
- memeseeks/models/ocr.py +35 -0
- memeseeks/models/textembed.py +19 -0
- memeseeks/models/vlm.py +68 -0
- memeseeks/online.py +51 -0
- memeseeks/phone.py +152 -0
- memeseeks/remote.py +131 -0
- memeseeks/retrieval.py +12 -0
- memeseeks/review.py +131 -0
- memeseeks/search.py +144 -0
- memeseeks/server.py +425 -0
- memeseeks/service.py +335 -0
- memeseeks/settings.py +60 -0
- memeseeks/tidy.py +187 -0
- memeseeks/warmup.py +92 -0
- memeseeks/web/app.js +1122 -0
- memeseeks/web/cat.js +86 -0
- memeseeks/web/fonts/JetBrainsMono-OFL.txt +93 -0
- memeseeks/web/fonts/NotoSerifSC-OFL.txt +94 -0
- memeseeks/web/fonts/memeseeks-mono.woff2 +0 -0
- memeseeks/web/fonts/memeseeks-serif.woff2 +0 -0
- memeseeks/web/fonts/serif-chars.txt +1 -0
- memeseeks/web/frame.js +81 -0
- memeseeks/web/icons/apple-touch-icon.png +0 -0
- memeseeks/web/icons/icon-192.png +0 -0
- memeseeks/web/icons/icon-512.png +0 -0
- memeseeks/web/icons/icon-maskable-512.png +0 -0
- memeseeks/web/icons/logo.svg +1 -0
- memeseeks/web/icons/memeseeks.ico +0 -0
- memeseeks/web/index.html +80 -0
- memeseeks/web/manifest.webmanifest +36 -0
- memeseeks/web/popcat.js +4 -0
- memeseeks/web/style.css +364 -0
- memeseeks/web/sw.js +27 -0
- memeseeks/web/tokens.css +81 -0
- memeseeks-0.3.0.dist-info/METADATA +217 -0
- memeseeks-0.3.0.dist-info/RECORD +55 -0
- memeseeks-0.3.0.dist-info/WHEEL +5 -0
- memeseeks-0.3.0.dist-info/entry_points.txt +2 -0
- memeseeks-0.3.0.dist-info/licenses/LICENSE +21 -0
- memeseeks-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
// ==UserScript==
|
|
2
|
+
// @name 迷因捕手
|
|
3
|
+
// @namespace memeseeks
|
|
4
|
+
// @version 0.3.0
|
|
5
|
+
// @description Collect the memes on the page you are reading into your own memeseeks library. Only acts when you click.
|
|
6
|
+
// @match *://*/*
|
|
7
|
+
// @noframes
|
|
8
|
+
// @inject-into auto
|
|
9
|
+
// @grant GM_xmlhttpRequest
|
|
10
|
+
// @grant GM_registerMenuCommand
|
|
11
|
+
// @grant GM_getValue
|
|
12
|
+
// @grant GM_setValue
|
|
13
|
+
// @connect 127.0.0.1
|
|
14
|
+
// @connect localhost
|
|
15
|
+
// @connect *
|
|
16
|
+
// ==/UserScript==
|
|
17
|
+
|
|
18
|
+
// What it does: when you click the cat (采集 meme), it lists the images on the page you are looking at and
|
|
19
|
+
// sends the ones you tick to your memeseeks server; a picture you drag from the page onto the cat is sent
|
|
20
|
+
// the same way. It never turns pages or runs on its own, and it sends nothing but the image, the site name,
|
|
21
|
+
// the page link, the page title and the 图集 you chose.
|
|
22
|
+
|
|
23
|
+
(function () {
|
|
24
|
+
"use strict";
|
|
25
|
+
|
|
26
|
+
const SERVER = "__MEMESEEKS_SERVER__";
|
|
27
|
+
const KEY = "__MEMESEEKS_KEY__";
|
|
28
|
+
const POPCAT = "__MEMESEEKS_POPCAT__"; // the logo's shapes (web/popcat.js), filled in by the server
|
|
29
|
+
const MIN_SIDE = 150; // smaller than this is an icon, avatar or sticker
|
|
30
|
+
const MAX_ASPECT = 4; // wider or taller than this is a banner or a divider
|
|
31
|
+
const GAP_MS = 500; // between two image downloads: about the pace of saving by hand
|
|
32
|
+
|
|
33
|
+
// ---------- where the memes are, per site (pure functions; tested with node) ----------
|
|
34
|
+
|
|
35
|
+
function absolute(url, base) {
|
|
36
|
+
try {
|
|
37
|
+
const u = new URL(url, base);
|
|
38
|
+
if (u.protocol === "http:" && /(^|\.)xhscdn\.com$/.test(u.hostname)) u.protocol = "https:";
|
|
39
|
+
return u.protocol === "https:" || u.protocol === "http:" ? u.href : null;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function doubanLarge(url) {
|
|
46
|
+
// /view/group_topic/{s,m,sqxs}/public/p1.webp -> the large version of the same picture
|
|
47
|
+
return url.replace(/\/view\/group_topic\/(?:s|m|sqxs|xs)\//, "/view/group_topic/l/");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function xhsImageUrl(image) {
|
|
51
|
+
// A note's image: the full-size default rendition when listed, else whatever url it has.
|
|
52
|
+
if (!image) return null;
|
|
53
|
+
const list = Array.isArray(image.infoList) ? image.infoList : [];
|
|
54
|
+
const full = list.find((x) => x && x.imageScene === "WB_DFT" && x.url);
|
|
55
|
+
return (full && full.url) || image.urlDefault || image.url || image.urlPre || null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function droppedImageUrl(html, uris, base) {
|
|
59
|
+
// A dragged picture carries its HTML (<img src=…>) and a URI list. For a picture inside a link the list
|
|
60
|
+
// holds the link's target, not the picture, so the <img> comes first.
|
|
61
|
+
const m = /<img\b[^>]*?\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i.exec(html || "");
|
|
62
|
+
const src = m ? (m[1] || m[2] || m[3] || "").replace(/&/g, "&") : "";
|
|
63
|
+
const uri = (uris || "").split(/\r?\n/).map((l) => l.trim()).find((l) => l && !l.startsWith("#")) || "";
|
|
64
|
+
return (src && absolute(src, base)) || (uri && absolute(uri, base)) || null; // "" would resolve to the page
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function looksLikeContent(w, h) {
|
|
68
|
+
if (!w || !h || w < MIN_SIDE || h < MIN_SIDE) return false;
|
|
69
|
+
return Math.max(w / h, h / w) <= MAX_ASPECT;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function siteOf(host) {
|
|
73
|
+
if (/(^|\.)tieba\.baidu\.com$/.test(host)) return "tieba";
|
|
74
|
+
if (/(^|\.)xiaohongshu\.com$/.test(host)) return "xiaohongshu";
|
|
75
|
+
if (/(^|\.)douban\.com$/.test(host)) return "douban";
|
|
76
|
+
return "generic";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SITE_NAMES = { tieba: "百度贴吧", xiaohongshu: "小红书", douban: "豆瓣" };
|
|
80
|
+
|
|
81
|
+
// ---------- reading the page ----------
|
|
82
|
+
|
|
83
|
+
function pageObject(el) {
|
|
84
|
+
// Page-side properties (Vue instances, state) are hidden from Firefox content scripts.
|
|
85
|
+
return (el && el.wrappedJSObject) || el;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function shownSize(img) {
|
|
89
|
+
const r = img.getBoundingClientRect();
|
|
90
|
+
return [Math.max(img.naturalWidth || 0, r.width), Math.max(img.naturalHeight || 0, r.height)];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function imgUrl(img) {
|
|
94
|
+
const src = img.currentSrc || img.src || "";
|
|
95
|
+
return src.startsWith("data:") ? null : absolute(src, location.href);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function genericImages(root) {
|
|
99
|
+
return [...(root || document).querySelectorAll("img")]
|
|
100
|
+
.filter((img) => looksLikeContent(...shownSize(img)))
|
|
101
|
+
.map((img) => ({ url: imgUrl(img), thumb: imgUrl(img) }));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function tiebaImages() {
|
|
105
|
+
const found = [];
|
|
106
|
+
// New PC pages: each picture is a LazyImage whose props hold the original URL, even before it loads.
|
|
107
|
+
for (const wrap of document.querySelectorAll(".image-card-wrapper .lazy-img-wrapper")) {
|
|
108
|
+
if (wrap.closest(".top-nav-bar, .frs-search-tag")) continue;
|
|
109
|
+
const vue = pageObject(wrap).__vue__;
|
|
110
|
+
const src = vue && vue.$props && vue.$props.src;
|
|
111
|
+
const img = wrap.querySelector("img");
|
|
112
|
+
const url = absolute(src || (img && imgUrl(img)) || "", location.href);
|
|
113
|
+
if (url) found.push({ url, thumb: (img && imgUrl(img)) || url });
|
|
114
|
+
}
|
|
115
|
+
// Older page layout.
|
|
116
|
+
for (const img of document.querySelectorAll("img.BDE_Image")) {
|
|
117
|
+
const url = imgUrl(img);
|
|
118
|
+
if (url) found.push({ url, thumb: url });
|
|
119
|
+
}
|
|
120
|
+
return found;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function xhsImages() {
|
|
124
|
+
const id = (location.pathname.match(/\/(?:explore|discovery\/item)\/([0-9a-f]{16,})/) || [])[1];
|
|
125
|
+
const state = pageObject(window).__INITIAL_STATE__;
|
|
126
|
+
try {
|
|
127
|
+
let map = state && state.note && state.note.noteDetailMap;
|
|
128
|
+
map = map && (map.value || map._value || map);
|
|
129
|
+
const entry = id && map && map[id];
|
|
130
|
+
const note = entry && (entry.note || (entry.value && entry.value.note));
|
|
131
|
+
const images = note && (note.imageList || []);
|
|
132
|
+
if (images && images.length) {
|
|
133
|
+
return {
|
|
134
|
+
title: note.title || (note.desc || "").slice(0, 60),
|
|
135
|
+
items: images.map(xhsImageUrl).map((u) => absolute(u, location.href)).filter(Boolean)
|
|
136
|
+
.map((url) => ({ url, thumb: url })),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
} catch (e) { /* fall back to the page */ }
|
|
140
|
+
const box = document.querySelector("#noteContainer, .note-container, .note-detail-mask");
|
|
141
|
+
return { items: box ? genericImages(box) : [] };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function doubanImages() {
|
|
145
|
+
const selectors = ".topic-content img, .topic-richtext img, #link-report img, #comments .reply-content img, .comment-photos img";
|
|
146
|
+
const found = [];
|
|
147
|
+
for (const img of document.querySelectorAll(selectors)) {
|
|
148
|
+
if (img.closest(".user-face, .pic")) continue; // avatars
|
|
149
|
+
const url = imgUrl(img);
|
|
150
|
+
if (url && /doubanio\.com\/view\//.test(url)) found.push({ url: doubanLarge(url), thumb: url });
|
|
151
|
+
}
|
|
152
|
+
return found;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function collect() {
|
|
156
|
+
const site = siteOf(location.hostname);
|
|
157
|
+
let title = (document.querySelector("h1") || {}).textContent || document.title;
|
|
158
|
+
let items;
|
|
159
|
+
if (site === "tieba") items = tiebaImages();
|
|
160
|
+
else if (site === "xiaohongshu") {
|
|
161
|
+
const got = xhsImages();
|
|
162
|
+
items = got.items;
|
|
163
|
+
if (got.title) title = got.title;
|
|
164
|
+
} else if (site === "douban") items = doubanImages();
|
|
165
|
+
if (!items || !items.length) items = genericImages();
|
|
166
|
+
const seen = new Set();
|
|
167
|
+
items = items.filter((it) => it.url && !seen.has(it.url) && seen.add(it.url));
|
|
168
|
+
title = (site === "tieba" ? document.title.replace(/[-_]百度贴吧\s*$/, "") : title).trim().slice(0, 200);
|
|
169
|
+
return { site: SITE_NAMES[site] || location.hostname, title, items };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------- talking to the network ----------
|
|
173
|
+
|
|
174
|
+
function gmRequest(opts) {
|
|
175
|
+
return new Promise((resolve, reject) => {
|
|
176
|
+
GM_xmlhttpRequest({ ...opts, onload: resolve, onerror: () => reject(new Error("网络错误")),
|
|
177
|
+
ontimeout: () => reject(new Error("超时")), timeout: 30000 });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function toBase64(buffer) {
|
|
182
|
+
const bytes = new Uint8Array(buffer);
|
|
183
|
+
let s = "";
|
|
184
|
+
for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
|
|
185
|
+
return btoa(s);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function download(url) {
|
|
189
|
+
// The image host sees the page you are on as the referrer, as when your browser shows the image.
|
|
190
|
+
const res = await gmRequest({ method: "GET", url, responseType: "arraybuffer", headers: { Referer: location.href } });
|
|
191
|
+
if (res.status !== 200 || !res.response) throw new Error(`下载失败(${res.status})`);
|
|
192
|
+
return res.response;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function stop(message) { // the server is not there (or does not know this script): no use trying the rest
|
|
196
|
+
const err = new Error(message);
|
|
197
|
+
err.offline = true;
|
|
198
|
+
return err;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function send(item, page, album) {
|
|
202
|
+
const buffer = await download(item.url);
|
|
203
|
+
let res;
|
|
204
|
+
try {
|
|
205
|
+
res = await gmRequest({
|
|
206
|
+
method: "POST", url: `${SERVER}/api/inbox`,
|
|
207
|
+
headers: { "Content-Type": "application/json", "X-Memeseeks-Key": KEY },
|
|
208
|
+
data: JSON.stringify({ image: toBase64(buffer), site: page.site, page_url: location.href,
|
|
209
|
+
page_title: page.title, image_url: item.url, ...(album ? { album } : {}) }),
|
|
210
|
+
});
|
|
211
|
+
} catch (err) {
|
|
212
|
+
throw stop("迷因捕手没在运行,打开桌面上的「迷因捕手」再试一次");
|
|
213
|
+
}
|
|
214
|
+
let body = {};
|
|
215
|
+
try { body = JSON.parse(res.responseText); } catch (e) { /* not JSON */ }
|
|
216
|
+
if (res.status === 401) throw stop("采集脚本需要重新安装,在迷因捕手的「设置 · 连接浏览器」里再装一次");
|
|
217
|
+
if (res.status !== 200) throw new Error(body.error || `迷因捕手没有响应(${res.status})`);
|
|
218
|
+
return body.status; // "added" or "duplicate"
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------- the cat button and the panel (docs/design.md, "The browser collector") ----------
|
|
222
|
+
// It runs on other people's sites, so everything lives in a closed shadow root with system fonts only.
|
|
223
|
+
|
|
224
|
+
const hiddenHosts = () => GM_getValue("hiddenHosts", []);
|
|
225
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
226
|
+
const reduce = () => matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
227
|
+
const NUM = /-?\d+(\.\d+)?/g;
|
|
228
|
+
const lerp = (a, b, t) => { const nb = b.match(NUM); let i = 0; return a.replace(NUM, (x) => (+x + (nb[i++] - x) * t).toFixed(1)); };
|
|
229
|
+
const ease = (x) => (x < 0.5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2);
|
|
230
|
+
const spit = (u) => (u < 0.6 ? 1.15 * ease(u / 0.6) : 1.15 - 0.15 * ease((u - 0.6) / 0.4));
|
|
231
|
+
const animate = (ms, fn) => new Promise((done) => { // fn(t in ms) every frame; resolves when done
|
|
232
|
+
const t0 = performance.now();
|
|
233
|
+
const step = (now) => { const t = Math.min(ms, now - t0); fn(t); if (t < ms) requestAnimationFrame(step); else done(); };
|
|
234
|
+
requestAnimationFrame(step);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const STYLE = `
|
|
238
|
+
:host { all: initial; }
|
|
239
|
+
* { box-sizing: border-box; }
|
|
240
|
+
.root { --ink: #161411; --paper: #F3EFE4; --accent: #FFD21F; --red: #DE3B2E; --blue: #1F4FA3; --muted: #6E685C;
|
|
241
|
+
font: 14px/1.5 system-ui, "PingFang SC", "Microsoft YaHei", sans-serif; color: var(--ink); }
|
|
242
|
+
.fab { position: fixed; left: 0; top: 0; z-index: 2147483000; touch-action: none; }
|
|
243
|
+
.fab.dragging .cat { cursor: grabbing; transform: translate(-2px, -2px) rotate(-4deg); box-shadow: 7px 7px 0 var(--ink); transition: none; }
|
|
244
|
+
.fab.dragging .label { opacity: 0 !important; }
|
|
245
|
+
.cat { width: 56px; height: 56px; padding: 5px; background: var(--paper); border: 2.5px solid var(--ink); box-shadow: 4px 4px 0 var(--ink);
|
|
246
|
+
cursor: pointer; display: block; transition: transform .12s; }
|
|
247
|
+
.cat:hover { transform: translate(-1px, -1px); box-shadow: 5px 5px 0 var(--ink); }
|
|
248
|
+
.cat:active { transform: translate(2px, 2px); box-shadow: 2px 2px 0 var(--ink); }
|
|
249
|
+
.cat svg { width: 100%; height: 100%; display: block; overflow: visible; }
|
|
250
|
+
/* beside the cat, out of the layout: however long its words, the cat stays where you put it */
|
|
251
|
+
.label { position: absolute; top: 50%; right: calc(100% + 10px); transform: translate(8px, -50%);
|
|
252
|
+
display: flex; align-items: stretch; background: var(--ink); color: #fff; font-weight: 700; line-height: 32px;
|
|
253
|
+
padding-right: 12px; white-space: nowrap; opacity: 0; transform: translateX(8px); pointer-events: none; transition: opacity .18s, transform .18s; }
|
|
254
|
+
.label b { width: 8px; background: var(--accent); margin-right: 10px; }
|
|
255
|
+
.label span { max-width: min(22em, 60vw); overflow: hidden; text-overflow: ellipsis; }
|
|
256
|
+
.fab.label-right .label { right: auto; left: calc(100% + 10px); transform: translate(-8px, -50%); }
|
|
257
|
+
.fab:hover .label, .fab.show-label .label { opacity: 1; transform: translate(0, -50%); }
|
|
258
|
+
.label.err b { background: var(--red); }
|
|
259
|
+
.fab.dropready .cat { transform: scale(1.12); box-shadow: 5px 5px 0 var(--ink); }
|
|
260
|
+
.fab.dropover .cat { transform: scale(1.3) rotate(-4deg); background: var(--accent); box-shadow: 7px 7px 0 var(--ink); }
|
|
261
|
+
.count { position: absolute; right: -8px; top: -10px; background: var(--ink); color: var(--accent); font: 700 12px/1 Consolas, monospace;
|
|
262
|
+
padding: 4px 6px; opacity: 0; transition: opacity .2s; }
|
|
263
|
+
.count.on { opacity: 1; }
|
|
264
|
+
.count.bump { animation: bump .22s cubic-bezier(.2, .8, .2, 1); }
|
|
265
|
+
@keyframes bump { from { transform: scale(1.35); } to { transform: scale(1); } }
|
|
266
|
+
.panel { position: fixed; z-index: 2147483000; width: min(560px, calc(100vw - 36px)); max-height: 72vh; display: flex; flex-direction: column;
|
|
267
|
+
background: var(--paper); border: 3px solid var(--ink); box-shadow: 6px 6px 0 var(--ink); animation: open .22s cubic-bezier(.2, .8, .2, 1); }
|
|
268
|
+
.panel[hidden] { display: none; }
|
|
269
|
+
@keyframes open { from { opacity: 0; transform: scale(.94); } }
|
|
270
|
+
.strip { display: flex; height: 12px; border-bottom: 3px solid var(--ink); flex: none; }
|
|
271
|
+
.strip i { border-right: 2px solid var(--ink); }
|
|
272
|
+
.strip i:last-child { border-right: 0; }
|
|
273
|
+
.strip .p { background: var(--paper); } .strip .y { background: var(--accent); } .strip .r { background: var(--red); } .strip .b { background: var(--blue); }
|
|
274
|
+
.head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; padding: 12px 16px 8px; }
|
|
275
|
+
.title { font: 900 19px/1.2 "Noto Serif SC", "Source Han Serif SC", "Songti SC", "SimSun", serif; }
|
|
276
|
+
.meta { font: 12px Consolas, monospace; color: var(--muted); }
|
|
277
|
+
.x { border: 0; background: none; font-size: 22px; line-height: 1; cursor: pointer; color: var(--ink); padding: 0 2px; }
|
|
278
|
+
.grid { overflow: auto; padding: 4px 16px 12px; display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 10px; }
|
|
279
|
+
.item { position: relative; aspect-ratio: 1; background: #fff; border: 2px solid #CFC6B2; cursor: pointer; overflow: hidden; padding: 0; }
|
|
280
|
+
.item img { width: 100%; height: 100%; object-fit: cover; display: block; transition: opacity .15s; }
|
|
281
|
+
.item.on { border-color: var(--ink); }
|
|
282
|
+
.item:not(.on) img { opacity: .4; }
|
|
283
|
+
.check { position: absolute; left: 6px; top: 6px; width: 20px; height: 20px; background: #fff; border: 2px solid var(--ink); display: grid; place-items: center; }
|
|
284
|
+
.item.on .check { background: var(--accent); }
|
|
285
|
+
.item.on .check::after { content: ""; width: 9px; height: 5px; border: solid var(--ink); border-width: 0 0 2.5px 2.5px; transform: rotate(-45deg) translate(1px, -1px); }
|
|
286
|
+
.tag { position: absolute; left: 0; right: 0; bottom: 0; font: 700 11px/20px system-ui, sans-serif; text-align: center; background: var(--ink); color: var(--accent); }
|
|
287
|
+
.tag.dup { color: #fff; } .tag.fail { background: var(--red); color: #fff; }
|
|
288
|
+
.foot { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; padding: 10px 16px 14px; border-top: 2px solid var(--ink); }
|
|
289
|
+
.btn { font: 700 14px system-ui, "Microsoft YaHei", sans-serif; padding: 7px 14px; border: 2px solid var(--ink); background: #fff; cursor: pointer; color: var(--ink); }
|
|
290
|
+
.btn.go { background: var(--accent); box-shadow: 3px 3px 0 var(--ink); }
|
|
291
|
+
.btn:disabled { opacity: .5; cursor: default; }
|
|
292
|
+
.where { display: flex; align-items: center; gap: 6px; margin-left: auto; font-size: 13px; }
|
|
293
|
+
.where select { font: 13px system-ui, "Microsoft YaHei", sans-serif; color: var(--ink); background: #fff; border: 2px solid var(--ink); padding: 5px 6px; max-width: 150px; }
|
|
294
|
+
.msg { flex: 1 1 100%; display: flex; align-items: stretch; font-size: 13px; min-height: 30px; background: var(--ink); color: #fff; }
|
|
295
|
+
.msg[hidden] { display: none; }
|
|
296
|
+
.msg b { width: 8px; margin-right: 10px; flex: none; background: var(--accent); }
|
|
297
|
+
.msg.err b { background: var(--red); }
|
|
298
|
+
.msg.note { background: none; color: var(--muted); } .msg.note b { display: none; }
|
|
299
|
+
.msg span { padding: 5px 10px 5px 0; }
|
|
300
|
+
.flyer { position: fixed; z-index: 2147483001; pointer-events: none; border: 2px solid var(--ink); object-fit: cover; }
|
|
301
|
+
@media (prefers-reduced-motion: reduce) { .panel, .count.bump { animation: none; } .label, .cat, .item img { transition: none; } }`;
|
|
302
|
+
|
|
303
|
+
function mount() {
|
|
304
|
+
if (location.origin === SERVER || hiddenHosts().includes(location.hostname)) return;
|
|
305
|
+
const host = document.createElement("div");
|
|
306
|
+
host.style.cssText = "all: initial; position: fixed; left: 0; top: 0; width: 0; height: 0; z-index: 2147483647;";
|
|
307
|
+
const shadow = host.attachShadow({ mode: "closed" });
|
|
308
|
+
shadow.innerHTML = `<style>${STYLE}</style>
|
|
309
|
+
<div class="root">
|
|
310
|
+
<div class="panel" hidden>
|
|
311
|
+
<div class="strip"><i class="p" style="flex:3"></i><i class="y" style="flex:1.4"></i><i class="p" style="flex:2.2"></i><i class="r" style="flex:.5"></i><i class="p" style="flex:2.6"></i><i class="b" style="flex:1"></i><i class="p" style="flex:1.2"></i></div>
|
|
312
|
+
<div class="head"><div><div class="title">采集 meme</div><div class="meta"></div></div><button class="x" type="button" title="关闭">×</button></div>
|
|
313
|
+
<div class="grid"></div>
|
|
314
|
+
<div class="foot">
|
|
315
|
+
<button class="btn all" type="button">全选</button><button class="btn none" type="button">全不选</button>
|
|
316
|
+
<label class="where">放进<select></select></label>
|
|
317
|
+
<button class="btn go" type="button">采集</button>
|
|
318
|
+
<div class="msg" hidden><b></b><span></span></div>
|
|
319
|
+
</div>
|
|
320
|
+
</div>
|
|
321
|
+
<div class="fab"><div class="label"><b></b><span>采集 meme</span></div>
|
|
322
|
+
<button class="cat" type="button" title="采集 meme(可以拖动)"><svg aria-hidden="true"></svg></button><div class="count"></div></div>
|
|
323
|
+
</div>`;
|
|
324
|
+
const $ = (sel) => shadow.querySelector(sel);
|
|
325
|
+
const fab = $(".fab"), catBtn = $(".cat"), svg = $(".cat svg"), panel = $(".panel"), grid = $(".grid"), go = $(".go");
|
|
326
|
+
const msg = $(".msg"), count = $(".count"), where = $(".where select"), labelText = $(".label span");
|
|
327
|
+
|
|
328
|
+
// ---- the cat: the logo's shapes; the mouth shuts and opens by interpolating them ----
|
|
329
|
+
const P = POPCAT, K = "#161411", [mx, my] = P.mc;
|
|
330
|
+
svg.setAttribute("viewBox", P.vb);
|
|
331
|
+
svg.innerHTML = `<path d="${P.body}" fill="#FFD21F" stroke="${K}" stroke-width="18" stroke-linejoin="round"/>
|
|
332
|
+
<path class="e0" fill="${K}"/><path class="e1" fill="${K}"/><path class="nose" fill="${K}"/><path class="mouth" fill="${K}"/>
|
|
333
|
+
<g class="bub">${P.bubble}</g>`;
|
|
334
|
+
const setCat = (closed, bubble) => {
|
|
335
|
+
[[".e0", P.final.eyes[0], P.closed.eyes[0]], [".e1", P.final.eyes[1], P.closed.eyes[1]],
|
|
336
|
+
[".nose", P.final.nose, P.closed.nose], [".mouth", P.final.mouth, P.closed.mouth]]
|
|
337
|
+
.forEach(([sel, a, b]) => svg.querySelector(sel).setAttribute("d", lerp(a, b, closed)));
|
|
338
|
+
svg.querySelector(".nose").setAttribute("opacity", closed >= 0.375 ? 1 : 0);
|
|
339
|
+
svg.querySelector(".bub").setAttribute("transform", `translate(${mx} ${my}) scale(${bubble.toFixed(3)}) translate(${-mx} ${-my})`);
|
|
340
|
+
};
|
|
341
|
+
setCat(0, 1);
|
|
342
|
+
// one gulp per meme that arrives; a new arrival mid-gulp takes over from the current mouth shape, so the
|
|
343
|
+
// cat never lags behind the memes
|
|
344
|
+
let gulpToken = null, gulpDone = Promise.resolve(), mouth = 0;
|
|
345
|
+
const gulp = () => {
|
|
346
|
+
if (reduce()) return gulpDone;
|
|
347
|
+
const token = {}, c0 = mouth;
|
|
348
|
+
gulpToken = token;
|
|
349
|
+
gulpDone = animate(150, (t) => {
|
|
350
|
+
if (gulpToken !== token) return;
|
|
351
|
+
mouth = t < 65 ? c0 + (1 - c0) * ease(t / 65) : 1 - ease((t - 65) / 85);
|
|
352
|
+
setCat(mouth, 0);
|
|
353
|
+
});
|
|
354
|
+
return gulpDone;
|
|
355
|
+
};
|
|
356
|
+
const spitOut = () => (reduce() ? (setCat(0, 1), Promise.resolve()) : animate(260, (t) => setCat(0, spit(t / 260))));
|
|
357
|
+
const shut = () => (reduce() ? (setCat(1, 0), Promise.resolve()) : animate(200, (t) => setCat(ease(t / 200), 0)));
|
|
358
|
+
|
|
359
|
+
// ---- the cat can be dragged anywhere; a tap still opens the panel ----
|
|
360
|
+
const MARGIN = 12, POS_KEY = `pos:${location.hostname}`; // where the cat sits, per site
|
|
361
|
+
let pos = GM_getValue(POS_KEY, null); // the cat's centre, as fractions of the window
|
|
362
|
+
const placePanel = () => { // open towards the middle of the screen
|
|
363
|
+
const r = catBtn.getBoundingClientRect(), w = innerWidth, h = innerHeight;
|
|
364
|
+
const pr = { width: panel.offsetWidth, height: panel.offsetHeight }; // its layout size: the opening animation scales it
|
|
365
|
+
const left = r.left + r.width / 2 > w / 2 ? r.right - pr.width : r.left;
|
|
366
|
+
const top = r.top + r.height / 2 > h / 2 ? r.top - pr.height - 16 : r.bottom + 16;
|
|
367
|
+
panel.style.left = `${Math.min(w - pr.width - MARGIN, Math.max(MARGIN, left))}px`;
|
|
368
|
+
panel.style.top = `${Math.min(h - pr.height - MARGIN, Math.max(MARGIN, top))}px`;
|
|
369
|
+
panel.style.transformOrigin = `${left < r.left ? "100%" : "0"} ${top < r.top ? "100%" : "0"}`;
|
|
370
|
+
};
|
|
371
|
+
const placeFab = () => {
|
|
372
|
+
const r = catBtn.getBoundingClientRect(), w = innerWidth, h = innerHeight;
|
|
373
|
+
const cx = pos ? pos.fx * w : w - MARGIN - r.width / 2 - 6, cy = pos ? pos.fy * h : h - MARGIN - r.height / 2 - 6;
|
|
374
|
+
const x = Math.min(w - MARGIN - r.width / 2 - 6, Math.max(MARGIN + r.width / 2, cx));
|
|
375
|
+
const y = Math.min(h - MARGIN - r.height / 2 - 6, Math.max(MARGIN + r.height / 2, cy));
|
|
376
|
+
fab.classList.toggle("label-right", x < w / 2); // the label slides out towards the middle
|
|
377
|
+
fab.style.transform = `translate(${x - r.width / 2}px, ${y - r.height / 2}px)`;
|
|
378
|
+
if (!panel.hidden) placePanel();
|
|
379
|
+
};
|
|
380
|
+
let drag = null;
|
|
381
|
+
catBtn.addEventListener("pointerdown", (e) => {
|
|
382
|
+
const r = catBtn.getBoundingClientRect();
|
|
383
|
+
drag = { id: e.pointerId, x0: e.clientX, y0: e.clientY, dx: e.clientX - (r.left + r.width / 2), dy: e.clientY - (r.top + r.height / 2), moved: false };
|
|
384
|
+
catBtn.setPointerCapture(e.pointerId);
|
|
385
|
+
});
|
|
386
|
+
catBtn.addEventListener("pointermove", (e) => {
|
|
387
|
+
if (!drag || e.pointerId !== drag.id) return;
|
|
388
|
+
if (!drag.moved && Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) < 5) return; // a tap, not a drag yet
|
|
389
|
+
drag.moved = true;
|
|
390
|
+
fab.classList.add("dragging");
|
|
391
|
+
pos = { fx: (e.clientX - drag.dx) / innerWidth, fy: (e.clientY - drag.dy) / innerHeight };
|
|
392
|
+
placeFab();
|
|
393
|
+
});
|
|
394
|
+
const endDrag = (e) => {
|
|
395
|
+
if (!drag || e.pointerId !== drag.id) return null;
|
|
396
|
+
const moved = drag.moved;
|
|
397
|
+
drag = null;
|
|
398
|
+
fab.classList.remove("dragging");
|
|
399
|
+
if (moved) GM_setValue(POS_KEY, pos);
|
|
400
|
+
return moved;
|
|
401
|
+
};
|
|
402
|
+
catBtn.addEventListener("pointercancel", endDrag);
|
|
403
|
+
catBtn.addEventListener("pointerup", (e) => {
|
|
404
|
+
const moved = endDrag(e);
|
|
405
|
+
if (moved === false) toggle();
|
|
406
|
+
});
|
|
407
|
+
catBtn.addEventListener("keydown", (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); toggle(); } });
|
|
408
|
+
addEventListener("resize", placeFab);
|
|
409
|
+
GM_registerMenuCommand("猫头回到右下角", () => { pos = null; GM_setValue(POS_KEY, null); placeFab(); });
|
|
410
|
+
|
|
411
|
+
// ---- the panel: this page's images, ticked; where to put them; collect ----
|
|
412
|
+
let page = null, items = [], busy = false;
|
|
413
|
+
const say = (text, kind = "ok") => { msg.className = `msg ${kind}`; msg.querySelector("span").textContent = text; msg.hidden = !text; };
|
|
414
|
+
const paint = () => {
|
|
415
|
+
grid.replaceChildren(...items.map((it, i) => {
|
|
416
|
+
const el = document.createElement("button");
|
|
417
|
+
el.type = "button";
|
|
418
|
+
el.className = `item${it.on ? " on" : ""}`;
|
|
419
|
+
el.dataset.i = i;
|
|
420
|
+
const img = document.createElement("img");
|
|
421
|
+
img.src = it.thumb;
|
|
422
|
+
img.alt = "";
|
|
423
|
+
img.referrerPolicy = "no-referrer-when-downgrade";
|
|
424
|
+
const check = document.createElement("span");
|
|
425
|
+
check.className = "check";
|
|
426
|
+
el.append(img, check);
|
|
427
|
+
if (it.done) {
|
|
428
|
+
const tag = document.createElement("span");
|
|
429
|
+
tag.className = `tag ${it.done}`;
|
|
430
|
+
tag.textContent = { ok: "已采集", dup: "库里已有", fail: "没采到" }[it.done];
|
|
431
|
+
el.append(tag);
|
|
432
|
+
}
|
|
433
|
+
return el;
|
|
434
|
+
}));
|
|
435
|
+
const n = items.filter((x) => x.on && !x.done).length;
|
|
436
|
+
$(".meta").textContent = `这一页 ${items.length} 张 · 已选 ${items.filter((x) => x.on).length}`;
|
|
437
|
+
go.textContent = `采集(${n})`;
|
|
438
|
+
go.disabled = busy || !n;
|
|
439
|
+
};
|
|
440
|
+
grid.addEventListener("click", (e) => {
|
|
441
|
+
const el = e.target.closest(".item");
|
|
442
|
+
if (!el || busy) return;
|
|
443
|
+
const it = items[+el.dataset.i];
|
|
444
|
+
if (it.done) return;
|
|
445
|
+
it.on = !it.on;
|
|
446
|
+
paint();
|
|
447
|
+
});
|
|
448
|
+
$(".all").addEventListener("click", () => { items.forEach((x) => { if (!x.done) x.on = true; }); paint(); });
|
|
449
|
+
$(".none").addEventListener("click", () => { items.forEach((x) => { if (!x.done) x.on = false; }); paint(); });
|
|
450
|
+
const hide = () => { panel.hidden = true; if (!busy) setCat(0, 1); }; // closing reopens a shut mouth
|
|
451
|
+
$(".x").addEventListener("click", hide);
|
|
452
|
+
panel.addEventListener("keydown", (e) => { if (e.key === "Escape") hide(); });
|
|
453
|
+
where.addEventListener("change", () => GM_setValue("album", where.value));
|
|
454
|
+
|
|
455
|
+
async function loadAlbums() { // the 图集 to put memes in: chosen here, remembered here
|
|
456
|
+
const keep = GM_getValue("album", "");
|
|
457
|
+
where.replaceChildren(new Option("只进图库", ""));
|
|
458
|
+
try {
|
|
459
|
+
const res = await gmRequest({ method: "GET", url: `${SERVER}/api/inbox/albums`, headers: { "X-Memeseeks-Key": KEY } });
|
|
460
|
+
if (res.status === 401) throw new Error("采集脚本需要重新安装,在迷因捕手的「设置 · 连接浏览器」里再装一次。");
|
|
461
|
+
if (res.status !== 200) throw new Error(`迷因捕手没有响应(${res.status})`);
|
|
462
|
+
for (const a of JSON.parse(res.responseText)) where.append(new Option(a.name, a.id));
|
|
463
|
+
where.value = [...where.options].some((o) => o.value === keep) ? keep : "";
|
|
464
|
+
return true;
|
|
465
|
+
} catch (err) {
|
|
466
|
+
say(err.message === "网络错误" ? "迷因捕手没在运行,打开桌面上的「迷因捕手」再试一次。" : err.message, "err");
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function toggle() {
|
|
472
|
+
if (!panel.hidden) { hide(); return; }
|
|
473
|
+
if (!busy) {
|
|
474
|
+
page = collect();
|
|
475
|
+
items = page.items.map((it) => ({ ...it, on: true, done: null }));
|
|
476
|
+
say(items.length ? "" : "这一页上没找到像梗图的图。", "note");
|
|
477
|
+
loadAlbums();
|
|
478
|
+
}
|
|
479
|
+
panel.hidden = false;
|
|
480
|
+
paint();
|
|
481
|
+
placePanel();
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const mouthPoint = () => { // the mouth centre on screen
|
|
485
|
+
const m = svg.getScreenCTM();
|
|
486
|
+
return { x: m.a * mx + m.e, y: m.d * my + m.f };
|
|
487
|
+
};
|
|
488
|
+
const fly = (el) => { // a copy of the thumbnail shrinks into the cat's mouth
|
|
489
|
+
if (!el || reduce()) return Promise.resolve();
|
|
490
|
+
const r = el.getBoundingClientRect(), to = mouthPoint();
|
|
491
|
+
const f = document.createElement("img");
|
|
492
|
+
f.src = el.querySelector("img").src;
|
|
493
|
+
f.className = "flyer";
|
|
494
|
+
Object.assign(f.style, { left: `${r.left}px`, top: `${r.top}px`, width: `${r.width}px`, height: `${r.height}px` });
|
|
495
|
+
shadow.append(f);
|
|
496
|
+
const dx = to.x - (r.left + r.width / 2), dy = to.y - (r.top + r.height / 2);
|
|
497
|
+
return f.animate([{ transform: "none", opacity: 1 },
|
|
498
|
+
{ transform: `translate(${dx * 0.55}px, ${dy * 0.35 - 40}px) scale(.55)`, opacity: 1, offset: 0.5 },
|
|
499
|
+
{ transform: `translate(${dx}px, ${dy}px) scale(.06)`, opacity: 0.9 }],
|
|
500
|
+
{ duration: 460, easing: "cubic-bezier(.5, 0, .75, 1)" }).finished.then(() => f.remove(), () => f.remove());
|
|
501
|
+
};
|
|
502
|
+
let eaten = 0;
|
|
503
|
+
const bumpCount = () => {
|
|
504
|
+
eaten += 1;
|
|
505
|
+
count.textContent = `+${eaten}`;
|
|
506
|
+
count.classList.remove("bump");
|
|
507
|
+
void count.offsetWidth;
|
|
508
|
+
count.classList.add("on", "bump");
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
go.addEventListener("click", async () => {
|
|
512
|
+
const todo = items.map((it, i) => [it, i]).filter(([it]) => it.on && !it.done);
|
|
513
|
+
if (!todo.length) return;
|
|
514
|
+
busy = true;
|
|
515
|
+
eaten = 0;
|
|
516
|
+
say("");
|
|
517
|
+
paint();
|
|
518
|
+
const album = where.value || null, albumName = album ? where.selectedOptions[0].textContent : "";
|
|
519
|
+
setCat(0, 0); // the mouth open, waiting to eat
|
|
520
|
+
fab.classList.add("show-label");
|
|
521
|
+
labelText.textContent = "采集中…";
|
|
522
|
+
const tally = { ok: 0, dup: 0, fail: 0 };
|
|
523
|
+
let lastError = "", offline = false;
|
|
524
|
+
const flights = [];
|
|
525
|
+
for (const [n, [it, i]] of todo.entries()) {
|
|
526
|
+
if (offline) { it.done = "fail"; it.on = false; tally.fail += 1; continue; }
|
|
527
|
+
say(`正在采集第 ${n + 1} / ${todo.length} 张…`, "note");
|
|
528
|
+
try {
|
|
529
|
+
const status = await send(it, page, album);
|
|
530
|
+
it.done = status === "added" ? "ok" : "dup";
|
|
531
|
+
tally[it.done] += 1;
|
|
532
|
+
const el = grid.querySelector(`.item[data-i="${i}"]`);
|
|
533
|
+
flights.push(fly(el).then(() => { gulp(); bumpCount(); }));
|
|
534
|
+
} catch (err) {
|
|
535
|
+
it.done = "fail";
|
|
536
|
+
tally.fail += 1;
|
|
537
|
+
lastError = err.message;
|
|
538
|
+
offline = Boolean(err.offline);
|
|
539
|
+
}
|
|
540
|
+
it.on = false;
|
|
541
|
+
if (n < todo.length - 1 && !offline) await sleep(GAP_MS);
|
|
542
|
+
}
|
|
543
|
+
await Promise.all(flights);
|
|
544
|
+
await gulpDone;
|
|
545
|
+
busy = false;
|
|
546
|
+
paint();
|
|
547
|
+
fab.classList.remove("show-label");
|
|
548
|
+
labelText.textContent = "采集 meme";
|
|
549
|
+
const into = albumName ? ` · 放进「${albumName}」` : "";
|
|
550
|
+
if (tally.ok + tally.dup) {
|
|
551
|
+
await spitOut(); // the bubble comes back: done
|
|
552
|
+
say(`新采集 ${tally.ok} 张 · 库里已有 ${tally.dup} 张${into} · 一分钟内就能搜到` +
|
|
553
|
+
(tally.fail ? ` · ${tally.fail} 张没采到(${lastError})` : ""), tally.fail ? "err" : "ok");
|
|
554
|
+
} else {
|
|
555
|
+
await shut(); // the mouth stays shut: nothing was caught
|
|
556
|
+
say(`${tally.fail} 张没采到:${lastError}`, "err");
|
|
557
|
+
}
|
|
558
|
+
setTimeout(() => count.classList.remove("on"), 1500);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
// ---- a picture dragged from the page onto the cat: it swallows it ----
|
|
562
|
+
let eatingDrop = false, flashTimer = null, countTimer = null;
|
|
563
|
+
const label = $(".label");
|
|
564
|
+
const flash = (text, kind = "ok") => { // a short word next to the cat (the panel may be closed)
|
|
565
|
+
clearTimeout(flashTimer);
|
|
566
|
+
labelText.textContent = text;
|
|
567
|
+
label.classList.toggle("err", kind === "err");
|
|
568
|
+
fab.classList.add("show-label");
|
|
569
|
+
flashTimer = setTimeout(() => {
|
|
570
|
+
fab.classList.remove("show-label");
|
|
571
|
+
label.classList.remove("err");
|
|
572
|
+
labelText.textContent = "采集 meme";
|
|
573
|
+
}, kind === "err" ? 4500 : 2200);
|
|
574
|
+
};
|
|
575
|
+
const settle = () => { // the drag ended without a drop on the cat
|
|
576
|
+
fab.classList.remove("dropready", "dropover");
|
|
577
|
+
if (busy || eatingDrop) return;
|
|
578
|
+
fab.classList.remove("show-label");
|
|
579
|
+
labelText.textContent = "采集 meme";
|
|
580
|
+
setCat(0, 1);
|
|
581
|
+
};
|
|
582
|
+
const carriesPicture = (e) => e.dataTransfer && [...e.dataTransfer.types].some((t) => t === "text/html" || t === "text/uri-list");
|
|
583
|
+
document.addEventListener("dragstart", (e) => {
|
|
584
|
+
const t = e.target;
|
|
585
|
+
const picture = t instanceof Element && (t.tagName === "IMG" || (t.tagName === "A" && t.querySelector("img")));
|
|
586
|
+
if (!picture || busy) return;
|
|
587
|
+
clearTimeout(flashTimer);
|
|
588
|
+
fab.classList.add("dropready", "show-label");
|
|
589
|
+
label.classList.remove("err");
|
|
590
|
+
labelText.textContent = "拖到猫嘴里";
|
|
591
|
+
setCat(0, 0); // the bubble goes in: ready to eat
|
|
592
|
+
}, true);
|
|
593
|
+
document.addEventListener("dragend", settle, true);
|
|
594
|
+
catBtn.addEventListener("dragenter", (e) => { if (carriesPicture(e)) { e.preventDefault(); fab.classList.add("dropover"); } });
|
|
595
|
+
catBtn.addEventListener("dragover", (e) => { if (carriesPicture(e)) { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; } });
|
|
596
|
+
catBtn.addEventListener("dragleave", () => fab.classList.remove("dropover"));
|
|
597
|
+
catBtn.addEventListener("drop", (e) => {
|
|
598
|
+
e.preventDefault();
|
|
599
|
+
fab.classList.remove("dropover", "dropready");
|
|
600
|
+
eatDropped(droppedImageUrl(e.dataTransfer.getData("text/html"), e.dataTransfer.getData("text/uri-list"), location.href));
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
async function eatDropped(url) {
|
|
604
|
+
if (busy) { flash("正在采集,稍等一下", "err"); return; }
|
|
605
|
+
if (!url) { setCat(0, 1); flash("这张图拿不到地址,用面板采集试试", "err"); return; }
|
|
606
|
+
busy = eatingDrop = true;
|
|
607
|
+
// the page's own rules know the full-size picture of a thumbnail (贴吧, 小红书, 豆瓣)
|
|
608
|
+
const here = collect();
|
|
609
|
+
const item = here.items.find((it) => it.thumb === url || it.url === url)
|
|
610
|
+
|| { url: siteOf(location.hostname) === "douban" ? doubanLarge(url) : url, thumb: url };
|
|
611
|
+
const album = GM_getValue("album", "") || null; // the 图集 chosen under 放进 last time
|
|
612
|
+
labelText.textContent = "吞下去…";
|
|
613
|
+
gulp();
|
|
614
|
+
try {
|
|
615
|
+
let status, note = "";
|
|
616
|
+
try {
|
|
617
|
+
status = await send(item, here, album);
|
|
618
|
+
} catch (err) {
|
|
619
|
+
if (!album || !/no such/.test(err.message)) throw err;
|
|
620
|
+
GM_setValue("album", ""); // that 图集 was deleted: into the library only
|
|
621
|
+
status = await send(item, here, null);
|
|
622
|
+
note = "(原来选的图集不在了)";
|
|
623
|
+
}
|
|
624
|
+
if (!count.classList.contains("on")) eaten = 0;
|
|
625
|
+
bumpCount();
|
|
626
|
+
clearTimeout(countTimer);
|
|
627
|
+
countTimer = setTimeout(() => count.classList.remove("on"), 2500);
|
|
628
|
+
await gulpDone;
|
|
629
|
+
await spitOut(); // the bubble comes back: got it
|
|
630
|
+
flash(`${status === "added" ? "吞下了" : "库里已有"}${note}`);
|
|
631
|
+
} catch (err) {
|
|
632
|
+
await shut(); // the mouth stays shut while it says why, then opens again
|
|
633
|
+
flash(`没采到:${err.message.split(/[,。]/)[0]}`, "err"); // the first clause: a label, not a letter
|
|
634
|
+
setTimeout(() => { if (!busy) setCat(0, 1); }, 4500);
|
|
635
|
+
} finally {
|
|
636
|
+
busy = eatingDrop = false;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
document.documentElement.append(host);
|
|
641
|
+
placeFab();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (typeof module !== "undefined") { // node test harness
|
|
645
|
+
module.exports = { absolute, doubanLarge, xhsImageUrl, looksLikeContent, siteOf, droppedImageUrl };
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
GM_registerMenuCommand("在这个网站隐藏采集 meme 按钮", () => {
|
|
649
|
+
GM_setValue("hiddenHosts", [...new Set([...hiddenHosts(), location.hostname])]);
|
|
650
|
+
location.reload();
|
|
651
|
+
});
|
|
652
|
+
GM_registerMenuCommand("在这个网站显示采集 meme 按钮", () => {
|
|
653
|
+
GM_setValue("hiddenHosts", hiddenHosts().filter((h) => h !== location.hostname));
|
|
654
|
+
location.reload();
|
|
655
|
+
});
|
|
656
|
+
if (document.body) mount();
|
|
657
|
+
else document.addEventListener("DOMContentLoaded", mount, { once: true });
|
|
658
|
+
})();
|