buyNow!

ふたばちゃんねるのスレッド上で貼られたAmazonとDLsiteのURLからタイトルとあれば価格と画像を取得する

当前为 2023-04-23 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name buyNow!
  3. // @namespace http://2chan.net/
  4. // @version 0.3.1
  5. // @description ふたばちゃんねるのスレッド上で貼られたAmazonとDLsiteのURLからタイトルとあれば価格と画像を取得する
  6. // @author ame-chan
  7. // @match http://*.2chan.net/b/res/*
  8. // @match https://*.2chan.net/b/res/*
  9. // @match https://kako.futakuro.com/futa/*
  10. // @match https://tsumanne.net/si/data/*
  11. // @icon https://www.google.com/s2/favicons?sz=64&domain=2chan.net
  12. // @grant GM_xmlhttpRequest
  13. // @connect amazon.co.jp
  14. // @connect www.amazon.co.jp
  15. // @connect amzn.to
  16. // @connect amzn.asia
  17. // @connect dlsite.com
  18. // @connect bookwalker.jp
  19. // @connect c.bookwalker.jp
  20. // @license MIT
  21. // ==/UserScript==
  22. (function () {
  23. 'use strict';
  24. const WHITE_LIST_URLS = [
  25. 'https://amazon.co.jp/',
  26. 'https://www.amazon.co.jp/',
  27. 'https://amzn.to/',
  28. 'https://amzn.asia/',
  29. 'http://www.dlsite.com/',
  30. 'https://www.dlsite.com/',
  31. 'http://dlsite.com/',
  32. 'https://dlsite.com/',
  33. 'http://bookwalker.jp/',
  34. 'http://www.bookwalker.jp/',
  35. 'https://bookwalker.jp/',
  36. 'https://www.bookwalker.jp/',
  37. ];
  38. const isAmazon = (path) => ['amazon.co.jp', 'amzn.to', 'amzn.asia'].some((url) => path.includes(url));
  39. const isDLsite = (path) => path.includes('dlsite.com');
  40. const isBookwalker = (path) => path.includes('bookwalker.jp');
  41. const WHITE_LIST_SELECTORS = (() => WHITE_LIST_URLS.map((url) => `a[href^="${url}"]`).join(','))();
  42. const isProductPage = (url) =>
  43. /https:\/\/(www\.)?amazon\.co\.jp\/.*\/[A-Z0-9]{10}/.test(url) ||
  44. /https:\/\/amzn.(asia|to)\//.test(url) ||
  45. /https?:\/\/(www\.)?dlsite\.com\/.+?\/[A-Z0-9]{8,}\.html/.test(url) ||
  46. /https?:\/\/bookwalker\.jp\/[a-z0-9]{10}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{12}/.test(url) ||
  47. /https?:\/\/bookwalker\.jp\/series\/[0-9]+\/list/.test(url);
  48. const getBrandName = (url) => {
  49. if (isAmazon(url)) {
  50. return 'amazon';
  51. } else if (isDLsite(url)) {
  52. return 'dlsite';
  53. } else if (isBookwalker(url)) {
  54. return 'bookwalker';
  55. }
  56. return '';
  57. };
  58. const addedStyle = `<style id="userjs-get-title-link">
  59. .userjs-title {
  60. display: flex;
  61. flex-direction: row;
  62. margin: 8px 0 16px;
  63. padding: 16px;
  64. line-height: 1.6 !important;
  65. color: #ff3860 !important;
  66. background-color: #fff;
  67. border-radius: 4px;
  68. }
  69. .userjs-title-inner {
  70. width: 400px;
  71. }
  72. .userjs-link {
  73. padding-right: 24px;
  74. background-image: url('data:image/svg+xml;charset=utf8,%3Csvg%20width%3D%2216%22%20height%3D%2216%22%20viewBox%3D%220%200%2038%2038%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20stroke%3D%22%23000%22%3E%3Cg%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%3Cg%20transform%3D%22translate(1%201)%22%20stroke-width%3D%222%22%3E%3Ccircle%20stroke-opacity%3D%22.5%22%20cx%3D%2218%22%20cy%3D%2218%22%20r%3D%2218%22%2F%3E%3Cpath%20d%3D%22M36%2018c0-9.94-8.06-18-18-18%22%3E%20%3CanimateTransform%20attributeName%3D%22transform%22%20type%3D%22rotate%22%20from%3D%220%2018%2018%22%20to%3D%22360%2018%2018%22%20dur%3D%221s%22%20repeatCount%3D%22indefinite%22%2F%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fg%3E%3C%2Fsvg%3E');
  75. background-repeat: no-repeat;
  76. background-position: right center;
  77. }
  78. .userjs-image {
  79. margin-right: 16px;
  80. max-width: none !important;
  81. max-height: none !important;
  82. transition: all 0.3s ease-in-out;
  83. }
  84. .userjs-price {
  85. display: block;
  86. margin-top: 4px;
  87. color: #228b22 !important;
  88. font-weight: 700;
  89. }
  90. [data-id="userjs-loading"] {
  91. margin-left: 4px;
  92. }
  93. </style>`;
  94. if (!document.querySelector('#userjs-get-title-link')) {
  95. document.head.insertAdjacentHTML('beforeend', addedStyle);
  96. }
  97. class FileReaderEx extends FileReader {
  98. constructor() {
  99. super();
  100. }
  101. #readAs(blob, ctx) {
  102. return new Promise((res, rej) => {
  103. super.addEventListener('load', ({ target }) => target?.result && res(target.result));
  104. super.addEventListener('error', ({ target }) => target?.error && rej(target.error));
  105. super[ctx](blob);
  106. });
  107. }
  108. readAsArrayBuffer(blob) {
  109. return this.#readAs(blob, 'readAsArrayBuffer');
  110. }
  111. readAsDataURL(blob) {
  112. return this.#readAs(blob, 'readAsDataURL');
  113. }
  114. }
  115. const fetchData = (url, responseType) =>
  116. new Promise((resolve) => {
  117. let options = {
  118. method: 'GET',
  119. url,
  120. timeout: 10000,
  121. onload: (result) => {
  122. if (result.status === 200) {
  123. return resolve(result.response);
  124. }
  125. return resolve(false);
  126. },
  127. onerror: () => resolve(false),
  128. ontimeout: () => resolve(false),
  129. };
  130. if (typeof responseType === 'string') {
  131. options = {
  132. ...options,
  133. responseType,
  134. };
  135. }
  136. GM_xmlhttpRequest(options);
  137. });
  138. const setFailedText = (linkElm) => {
  139. linkElm.insertAdjacentHTML('afterend', `<span class="userjs-title">データ取得失敗</span>`);
  140. };
  141. const getPriceText = ({ targetDocument, brandName }) => {
  142. if (brandName === '') return '';
  143. const targetElement = {
  144. amazon: () => {
  145. const priceRange = () => {
  146. const rangeElm = targetDocument.querySelector('.a-price-range');
  147. if (!rangeElm) return 0;
  148. rangeElm.querySelectorAll('.a-offscreen').forEach((el) => el.remove());
  149. return rangeElm.textContent?.replace(/[\s]+/g, '');
  150. };
  151. const price =
  152. targetDocument.querySelector('#twister-plus-price-data-price')?.value ||
  153. targetDocument.querySelector('#kindle-price')?.textContent?.replace(/[\s¥]+/g, '') ||
  154. targetDocument.querySelector('[name="displayedPrice"]')?.value;
  155. return Number(price) || priceRange() || 0;
  156. },
  157. dlsite: () => {
  158. const url = targetDocument.querySelector('meta[property="og:url"]')?.content;
  159. const productId = url.split('/').pop()?.replace('.html', '');
  160. const priceElm = targetDocument.querySelector(`[data-product_id="${productId}"][data-price]`);
  161. return parseInt(priceElm?.getAttribute('data-price') || '', 10);
  162. },
  163. bookwalker: () => {
  164. const price =
  165. Number(
  166. targetDocument
  167. .querySelector('.m-tile-list .m-tile .m-book-item__price-num')
  168. ?.textContent?.replace(/,/g, ''),
  169. ) || Number(targetDocument.querySelector('#jsprice')?.textContent?.replace(/[円,]/g, ''));
  170. return Number.isInteger(price) && price > 0 ? price : 0;
  171. },
  172. };
  173. const price = targetElement[brandName]();
  174. let priceText = price;
  175. if (!price) return '';
  176. if (typeof price === 'number' && Number.isInteger(price) && price > 0) {
  177. priceText = new Intl.NumberFormat('ja-JP', {
  178. style: 'currency',
  179. currency: 'JPY',
  180. }).format(price);
  181. }
  182. return `<span class="userjs-price">${priceText}</span>`;
  183. };
  184. const setTitleText = ({ targetDocument, linkElm, brandName }) => {
  185. const titleElm = targetDocument.querySelector('title');
  186. if (!titleElm || !titleElm?.textContent) return;
  187. const priceText = getPriceText({
  188. targetDocument,
  189. brandName,
  190. });
  191. const nextSibling = linkElm.nextElementSibling;
  192. if (nextSibling && nextSibling instanceof HTMLElement && nextSibling.tagName.toLowerCase() === 'br') {
  193. nextSibling.style.display = 'none';
  194. }
  195. linkElm?.insertAdjacentHTML(
  196. 'afterend',
  197. `<div class="userjs-title">
  198. <span class="userjs-title-inner">${titleElm.textContent}${priceText}</span>
  199. </div>`,
  200. );
  201. };
  202. const setImageElm = async ({ targetDocument, titleTextElm, brandName }) => {
  203. if (brandName === '') return;
  204. const imageEventHandler = (e) => {
  205. const self = e.currentTarget;
  206. if (!(self instanceof HTMLImageElement)) return;
  207. if (self.width === 100) {
  208. self.width = 600;
  209. } else {
  210. self.width = 100;
  211. }
  212. };
  213. const targetElement = {
  214. amazon:
  215. targetDocument.querySelector('#landingImage')?.src ||
  216. targetDocument.querySelector('.unrolledScrollBox li:first-child img')?.src ||
  217. targetDocument.querySelector('[data-a-image-name]')?.src ||
  218. targetDocument.querySelector('#imgBlkFront')?.src,
  219. dlsite: targetDocument.querySelector('meta[property="og:image"]')?.content,
  220. bookwalker:
  221. targetDocument.querySelector('.m-tile-list .m-tile img')?.getAttribute('data-original') ||
  222. targetDocument.querySelector('meta[property="og:image"]')?.content,
  223. };
  224. const imagePath = targetElement[brandName];
  225. if (typeof imagePath !== 'string') return;
  226. const blob = await fetchData(imagePath, 'blob');
  227. if (!blob) return;
  228. const dataUrl = await new FileReaderEx().readAsDataURL(blob);
  229. const img = document.createElement('img');
  230. img.src = dataUrl;
  231. img.width = 100;
  232. img.classList.add('userjs-image');
  233. titleTextElm.querySelector('.userjs-title-inner')?.insertAdjacentElement('beforebegin', img);
  234. img.addEventListener('click', imageEventHandler);
  235. };
  236. const setLoading = (linkElm) => {
  237. const parentElm = linkElm.parentElement;
  238. if (
  239. parentElm instanceof HTMLFontElement ||
  240. !isProductPage(linkElm.href) ||
  241. parentElm?.querySelector('[data-id="userjs-loading"]')
  242. ) {
  243. return;
  244. }
  245. linkElm.classList.add('userjs-link');
  246. };
  247. const removeLoading = (targetElm) => targetElm.classList.remove('userjs-link');
  248. const isAmazonConfirmAdultPage = (targetDocument) => targetDocument.querySelector('#black-curtain-warning') !== null;
  249. const getAmazonConfirmAdultPageHref = (targetDocument) => {
  250. const yesBtnLinkElm = targetDocument.querySelector('#black-curtain-yes-button a');
  251. if (yesBtnLinkElm instanceof HTMLAnchorElement) {
  252. return `https://www.amazon.co.jp${yesBtnLinkElm.getAttribute('href')}`;
  253. }
  254. return false;
  255. };
  256. const getAmazonAdultDocument = async (targetDocument, linkElm, parser) => {
  257. const newHref = getAmazonConfirmAdultPageHref(targetDocument);
  258. const htmlData = newHref && (await fetchData(newHref));
  259. if (!htmlData) {
  260. setFailedText(linkElm);
  261. removeLoading(linkElm);
  262. return false;
  263. }
  264. return parser.parseFromString(htmlData, 'text/html');
  265. };
  266. const insertURLData = async (linkElm) => {
  267. const parentElm = linkElm.parentElement;
  268. if (parentElm instanceof HTMLFontElement || !isProductPage(linkElm.href)) {
  269. return;
  270. }
  271. const htmlData = await fetchData(linkElm.href);
  272. if (!htmlData) {
  273. setFailedText(linkElm);
  274. removeLoading(linkElm);
  275. return;
  276. }
  277. const parser = new DOMParser();
  278. let targetDocument = parser.parseFromString(htmlData, 'text/html');
  279. // アダルトページ確認画面スキップ
  280. if (isAmazonConfirmAdultPage(targetDocument)) {
  281. const amazonAdultDocument = await getAmazonAdultDocument(targetDocument, linkElm, parser);
  282. if (amazonAdultDocument) {
  283. targetDocument = amazonAdultDocument;
  284. }
  285. }
  286. const brandName = getBrandName(linkElm.href);
  287. setTitleText({
  288. targetDocument,
  289. linkElm,
  290. brandName,
  291. });
  292. const titleTextElm = linkElm.nextElementSibling;
  293. if (titleTextElm) {
  294. await setImageElm({
  295. targetDocument,
  296. titleTextElm,
  297. brandName,
  298. });
  299. }
  300. removeLoading(linkElm);
  301. };
  302. const replaceDefaultURL = (targetElm) => {
  303. const linkElms = targetElm.querySelectorAll('a[href]');
  304. const replaceUrl = (url) => {
  305. const regex = /http:\/\/www\.dlsite\.com\/(.+?)\/dlaf\/=\/link\/work\/aid\/[a-zA-Z]+\/id\/(RJ[0-9]+)\.html/;
  306. const newUrlFormat = 'https://www.dlsite.com/$1/work/=/product_id/$2.html';
  307. return url.replace(regex, newUrlFormat);
  308. };
  309. for (const linkElm of linkElms) {
  310. const brandName = getBrandName(linkElm.href);
  311. const href = linkElm.getAttribute('href');
  312. if (brandName === 'dlsite') {
  313. linkElm.href = replaceUrl(href.replace('/bin/jump.php?', ''));
  314. } else {
  315. linkElm.href = href.replace('/bin/jump.php?', '');
  316. }
  317. }
  318. };
  319. const searchLinkElements = (targetElm) => {
  320. const linkElms = targetElm.querySelectorAll(WHITE_LIST_SELECTORS);
  321. if (!linkElms.length) return;
  322. for (const linkElm of linkElms) {
  323. if (!(linkElm instanceof HTMLElement)) continue;
  324. setLoading(linkElm);
  325. void insertURLData(linkElm);
  326. }
  327. };
  328. const mutationLinkElements = async (mutations) => {
  329. for (const mutation of mutations) {
  330. for (const addedNode of mutation.addedNodes) {
  331. if (!(addedNode instanceof HTMLElement)) continue;
  332. replaceDefaultURL(addedNode);
  333. searchLinkElements(addedNode);
  334. }
  335. }
  336. };
  337. const threadElm = document.querySelector('.thre');
  338. if (threadElm instanceof HTMLElement) {
  339. replaceDefaultURL(threadElm);
  340. searchLinkElements(threadElm);
  341. const observer = new MutationObserver(mutationLinkElements);
  342. observer.observe(threadElm, {
  343. childList: true,
  344. });
  345. }
  346. })();