import chalk from "chalk"; import { parseHTML } from "linkedom"; import { isEmpty, isNil } from "lodash-es"; import type { FC2Video, FC2Work } from "@/interfaces"; import { nowMs, parseYmdUtc } from "@/utils/dates"; import { FC2_COOKIE } from "@/utils/env"; import { parsePriceJPY } from "@/utils/money"; const SITE_PREFIX = "https://adult.contents.fc2.com"; const FETCH_TIMEOUT_MS = 30_000; export const articleUrl = (id: string): string => `${SITE_PREFIX}/article/${id}/`; const fc2Fetch = (url: string): Promise => fetch(url, { headers: { Cookie: FC2_COOKIE }, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }); const extractPriceFromCard = (card: Element): { priceYen: number | null; onPromo: boolean } => { const priceEl = card.querySelector(".c-cntCard-110-f_price") ?? card.querySelector(".c-price-101") ?? card.querySelector('[class*="price"]'); if (isNil(priceEl)) return { priceYen: null, onPromo: false }; const saleEl = priceEl.querySelector(".c-price-101_sale") ?? priceEl.querySelector(".sale") ?? card.querySelector(".c-cntCard-110-f_label") ?? priceEl.querySelector('[class*="sale"]'); const onPromo = !isNil(saleEl); const currentText = saleEl?.textContent ?? priceEl.textContent; const priceYen = parsePriceJPY(currentText); return { priceYen, onPromo }; }; export const collectWorksFromListing = async (author: string): Promise => { const pagination = { page: 1, total: 1 }; const cards: Element[] = []; while (pagination.page <= pagination.total) { const targetUrl = new URL(SITE_PREFIX); targetUrl.pathname = `/users/${author}/articles`; targetUrl.searchParams.set("sort", "date"); targetUrl.searchParams.set("order", "desc"); targetUrl.searchParams.set("page", pagination.page.toString()); console.info(chalk.dim(targetUrl.toString())); const res = await fc2Fetch(targetUrl.toString()); if (!res.ok) { console.warn( chalk.yellow(`FC2 page ${pagination.page} returned ${res.status}; stopping pagination`) ); break; } const data = await res.text(); const { document } = parseHTML(data); const pageCards = Array.from( document.querySelectorAll(".seller_user_articlesList .c-cntCard-110-f") ); if (pageCards.length === 0) { console.warn( chalk.yellow(`FC2 page ${pagination.page}: no cards matched (layout changed or empty)`) ); } cards.push(...pageCards); if (pagination.page === 1) { const pageNums = Array.from(document.querySelectorAll(".c-pager-101 .items")).map( (item) => parseInt(item.innerHTML) || 0 ); const detected = Math.max(0, ...pageNums); if (detected === 0 && pageCards.length > 0) { console.warn(chalk.yellow("FC2: pager not detected; assuming single page")); } pagination.total = detected || 1; } pagination.page++; } const now = nowMs(); const works: FC2Work[] = []; let dropped = 0; for (const card of cards) { const aTag = card.querySelector(".c-cntCard-110-f_thumb a"); if (isNil(aTag)) { dropped++; continue; } const { title, href } = aTag; const id = new URLSearchParams(href.split("?")[1]).get("id") ?? ""; if (isEmpty(id)) { dropped++; continue; } const duration = aTag.querySelector("span.c-cntCard-110-f_thumb_num")?.innerHTML ?? null; const { priceYen, onPromo } = extractPriceFromCard(card); works.push({ id, author, title, duration: duration || null, publishedAt: null, priceYen, onPromo, observedAt: now, source: "listing" }); } if (dropped > 0) { console.warn(chalk.yellow(`FC2: dropped ${dropped}/${cards.length} cards due to missing data`)); } return works; }; export const collectionVideos = async (author: string): Promise => { const works = await collectWorksFromListing(author); return works.map((w) => ({ title: w.title, id: w.id, url: articleUrl(w.id), duration: w.duration ?? "00:00" })); }; const JP_DATE_RE = /(\d{4})[/\-.年](\d{1,2})[/\-.月](\d{1,2})/; const parseFC2Date = (text: string | null | undefined): number | null => { if (!text) return null; const m = text.match(JP_DATE_RE); if (!m || !m[1] || !m[2] || !m[3]) return null; return parseYmdUtc(m[1], m[2], m[3]); }; const FC2_PPV_PREFIX_RE = /^FC2[- ]PPV[- ]\d+\s+/i; const FC2_TITLE_SUFFIX_RE = /\s*\|\s*FC2 Content Market.*$/i; export interface FC2WorkDetail { publishedAt: number | null; priceYen: number | null; onPromo: boolean; title: string | null; } export const fetchWorkDetail = async (id: string): Promise => { const url = `${SITE_PREFIX}/article/${id}/`; const res = await fc2Fetch(url); if (!res.ok) { throw new Error(`FC2 detail ${id}: HTTP ${res.status}`); } const html = await res.text(); const { document } = parseHTML(html); // Title: og:title is the cleanest source — typically "FC2-PPV-{id} {real title}". // Fall back to , then strip the id prefix and site suffix. The previous // `h1` fallback picked up the site header "Content Market" on every page. const ogTitle = document.querySelector<HTMLMetaElement>('meta[property="og:title"]')?.getAttribute("content") ?? document.querySelector("title")?.textContent ?? null; const cleanedTitle = ogTitle ? ogTitle.replace(FC2_PPV_PREFIX_RE, "").replace(FC2_TITLE_SUFFIX_RE, "").trim() : null; const title = cleanedTitle && cleanedTitle.length > 0 ? cleanedTitle : null; // Publish date: spec rows live in multiple <div class="items_article_softDevice"> // blocks; the date one reads "Sale Day : YYYY/MM/DD" (EN) or "販売日 : YYYY/MM/DD" (JP). let publishedAt: number | null = null; for (const p of Array.from(document.querySelectorAll(".items_article_softDevice p"))) { const text = p.textContent; if (!text) continue; if (!/Sale Day|販売日|发布日|Sold Date/i.test(text)) continue; const parsed = parseFC2Date(text); if (parsed !== null) { publishedAt = parsed; break; } } // Price: .normal_price lives inside .items_article_headerPriceArea and on the // mylist box. If a sibling element with "sale"/"discount"/"special" in its class // is present, that is the active sale price and flags onPromo. const priceArea = document.querySelector(".items_article_headerPriceArea"); const saleEl = priceArea?.querySelector( '[class*="sale_price"], [class*="discount"], [class*="special_price"]' ) ?? null; const priceSource = saleEl?.textContent ?? priceArea?.querySelector(".normal_price")?.textContent ?? document.querySelector(".normal_price")?.textContent ?? null; const priceYen = parsePriceJPY(priceSource); const onPromo = saleEl !== null; return { publishedAt, priceYen, onPromo, title }; };