"""FC2 五站:发行方商品页、fc2cmadb 镜像页、FC2PPV-DB、JAVten 与 JavArchive 转载页的取页与解析。""" import json import unittest import urllib.parse from peach.http import HttpResponse from peach.sources import FailureReason, Page, Session, SourceFailure from peach.sources.fc2 import FC2, STUDIO, UNRECOGNISED, Fc2Source, canonical_code, runtime_minutes, video_id from peach.sources.fc2cmadb import COMPONENT as MIRROR_COMPONENT from peach.sources.fc2cmadb import FC2CMADB, Fc2cmadbSource, parse_actresses, partial_headers from peach.sources.fc2ppvdb import FC2PPVDB, Fc2ppvdbSource, japanese_date from peach.sources.javarchive import JAVARCHIVE, JavArchiveSource, links from peach.sources import javten as javten_module from peach.sources.javten import JAVTEN, JavtenSource CHALLENGE = ('Just a moment...' '
Verifying you are human.
') #: 站上那份前端资源的指纹,2026-09-22 实测形态。 MIRROR_VERSION = "fcb3b524d4c7f8f3d2c38e437b35b7a9" COVER = "https://storage92000.contents.fc2.com/file/261/26076760/1711813737.76.jpg" TITLE = "みお(19)可愛い巨乳JDの初アナル貫通動画" def shop_page(video="4364209", title=TITLE, image=COVER, slug="otonakamenz", seller="大人仮面Z", sold="2024/03/31", runtime="41:50", tags=("おっぱい", "お尻", "素人")): """商品页里解析用得上的那几块,标记与站上一致。""" product = {"@type": "Product", "@id": f"article:{video}", "sku": video, "name": title, "description": "本編と別アングルの2本立て。", "image": {"url": image, "@type": "ImageObject"}, "brand": {"name": None, "url": f"https://adult.contents.fc2.com/users/{slug}/", "@type": "Brand"}} links = "".join(f'{tag}' for tag in tags) return ( '' f'' f'

{title}

' f'{seller}
' f'

{runtime}

' f'

販売日 : {sold}

' f'

商品タグ

{links}
' ) def gone_page(): """下架后站上仍回 200,只是正文里那份 Product 不见了。""" return ('' '

見つかりませんでした

') ARCHIVE_LINK = "/926949-FC2-PPV-4137487-Gカップ・脅威-マジ凄いです!脅威のパイパーGカップグラマラス!-pn.html" ARCHIVE_TITLE = "Gカップ・脅威 マジ凄いです!脅威のパイパーGカップグラマラス!" ARCHIVE_PICTURES = "https://img.javstore.net/images/2023/12/26/" def search_page(video="4137487", picture=True, reposted=False): """搜索结果。侧栏的本周热门与归档菜单用的是同一种地址形状,所以它们也在这一页里。 第二条那部没有图,只有标题链接——站上实测一半的结果是这样(`1863914`、`2110084`)。 `reposted` 是同一个商品的第二条:不同转存者各发一次,文章号、番号写法和标题都不同。 """ heading = f"FC2-PPV-{video} {ARCHIVE_TITLE}" thumb = (f'{heading}' if picture else "") repost = (f'
  • 转存的那一条
  • ' if reposted else "") return ( '' '
    " ) def archive_page(video="4137487", title=None, large="{root}{video}pl.jpg", small="{root}{video}ps.jpg", tags="ハメ撮り|素人|爆乳", release="2023/11/21", runtime="50:03"): """作品页。封面在两个图位上,标签、日期、时长在正文那块资料里,转存者可填可不填。 `` 里那条 `description` 照站上的样子写:它把同一段话截断成 `…|S級...` 塞进 `content`,整页搜「标签:」会先命中它。余下几段是转载来的网盘链接,一概不取。 """ heading = title if title is not None else f"FC2-PPV-{video} {ARCHIVE_TITLE}" link = ARCHIVE_LINK if video == "4137487" else f"/926949-FC2-PPV-{video}-x-pn.html" slots = [] if large: slots.append('
    ' f'
    ') if small: slots.append(f'{heading}') rows = "".join(f"{label}:{value}
    " for label, value in (("标签", tags), ("日期", release), ("时长", runtime)) if value) return ( '' f'' f'
    {heading}
    ' + "".join(slots) + f"商品名称:{heading}
    {rows}" # 多帧拼成的长条预览,正文里标着 Preview,不是封面。 f'Original version: CLICK HERE!' f'{heading}' '
    https://rapidgator.net/file/deadbeef/x.mp4.html
    ' ) def mirror_page(video="3189161", title="【無】コスプレシリーズ", image=COVER, slug="rina_vlog", seller="梨奈の射精動画@個人撮影", release="2023-02-19", duration="46:06", tags=("ハメ撮り", "フェラ"), component=MIRROR_COMPONENT, version=MIRROR_VERSION): """fc2cmadb 是 Laravel + Inertia,整棵 props 树放在一个 script 里,正文是空壳。""" page = {"component": component, "version": version, "url": f"/articles/{video}", "props": {"appName": "FC2CMADB", "auth": {"user": None}, "article": {"id": 364910, "video_id": int(video), "title": title, "release_date": release, "duration": duration, "image_url": image, "writer": {"id": 1818, "slug": slug, "name": seller}, "tags": [{"name": tag} for tag in tags]}}} return (f'
    ') def mirror_actresses(*named): """点名 `actresses` 那一跳回来的东西:整份 Inertia 响应,props 里只剩要的那一栏。""" listed = [{"id": 3667 + at, "name": name, "description": None, "alias_name": "ののみやすず 逢坂りの きたのあや", "redirect_url": None, "pivot": {"article_id": 521612, "actress_id": 3667 + at}} for at, name in enumerate(named)] return json.dumps({"component": MIRROR_COMPONENT, "version": MIRROR_VERSION, "url": "/articles/3189161", "props": {"errors": {}, "actresses": listed}}, ensure_ascii=False) def page(html, url="https://example.test/"): """站上回的是字节,解析器按 UTF-8 读。""" return Page(url, html.encode() if isinstance(html, str) else html) def article(html, code): return Fc2Source().parse(page(html), code).payload() def mirror(html, code): return Fc2cmadbSource().parse(page(html), code).payload() def archive(html, code): return JavArchiveSource().parse(page(html), code).payload() def serve(pages): """按地址回页面的假传输。键是解码后的地址;值可以是按请求头挑页面的函数。缺的回 404。""" calls = [] def call(request, timeout, limit): url = urllib.parse.unquote(request.url) calls.append((url, request.headers.get("Referer"), limit, request.headers.get("X-Inertia-Partial-Data"))) body = pages.get(url) if callable(body): body = body(request.headers) if isinstance(body, str): body = body.encode() return HttpResponse(200 if body is not None else 404, {}, body or b"", request.url) call.calls = calls return call class Fc2CodeTests(unittest.TestCase): def test_a_ledger_code_becomes_the_shop_number_in_every_writing_it_wears(self): for code in ("FC2-PPV-4364209", "FC2PPV-4364209", "fc2_ppv_4364209", "FC2 PPV 4364209", "FC2-4364209"): self.assertEqual(video_id(code), "4364209", code) self.assertEqual(canonical_code("4364209"), "FC2-PPV-4364209") self.assertEqual(Fc2Source().article_url("FC2PPV-4364209"), "https://adult.contents.fc2.com/article/4364209/") self.assertEqual(Fc2cmadbSource().article_url("FC2PPV-4364209"), "https://fc2cmadb.com/articles/4364209") def test_a_collection_part_asks_nowhere(self): # `FC2-PPV-3312576-1` 是 21 段合集里的一段,两个站都只有整份合集那一页。 # 认出商品号就意味着把合集封面套给每一段,屏幕上是 21 个内容顶着同一张图。 for code in ("FC2-PPV-3312576-1", "FC2-PPV-3312576_4", "", "ABW-358"): self.assertEqual(video_id(code), "", code) self.assertEqual(Fc2Source().article_url(code), "", code) self.assertEqual(Fc2cmadbSource().article_url(code), "", code) self.assertEqual(JavArchiveSource().search_url(code), "", code) def test_a_code_without_a_shop_number_fails_every_site_before_any_request(self): for site in (Fc2Source(), Fc2cmadbSource(), JavArchiveSource()): transport = serve({}) with self.subTest(site=site.config.name), self.assertRaises(SourceFailure) as caught: site.records("FC2-PPV-3312576-1", session=Session(transport)) self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, UNRECOGNISED)) self.assertEqual(transport.calls, []) def test_both_runtime_writings_read_as_minutes(self): self.assertEqual(runtime_minutes("41:50"), 41.83) self.assertEqual(runtime_minutes("1:23:45"), 83.75) self.assertIsNone(runtime_minutes("")) self.assertIsNone(runtime_minutes("収録時間 41:50")) class Fc2ShopPageTests(unittest.TestCase): def test_the_shop_page_gives_every_field_the_review_card_shows(self): found = article(shop_page(), "FC2-PPV-4364209") self.assertEqual(found["id"], "FC2-PPV-4364209") self.assertEqual(found["content_id"], "4364209") self.assertEqual(found["source_url"], "https://adult.contents.fc2.com/article/4364209/") self.assertEqual(found["title"], TITLE) self.assertEqual(found["release_date"], "2024-03-31") self.assertEqual(found["runtime"], 41.83) self.assertEqual(found["maker"], STUDIO) self.assertEqual(found["label"], "大人仮面Z") self.assertEqual(found["seller_url"], "https://adult.contents.fc2.com/users/otonakamenz/") self.assertEqual(found["genres"], ["おっぱい", "お尻", "素人"]) self.assertEqual(found["cover_url"], COVER) self.assertEqual(found["cover_urls"], [COVER]) def test_the_whole_payload_matches_the_snapshot_shape_key_by_key(self): self.assertEqual(article(shop_page(), "FC2-PPV-4364209"), { "id": "FC2-PPV-4364209", "content_id": "4364209", "source_url": "https://adult.contents.fc2.com/article/4364209/", "title": TITLE, "description": "本編と別アングルの2本立て。", "actresses": [], "maker": "FC2-PPV", "label": "大人仮面Z", "seller_url": "https://adult.contents.fc2.com/users/otonakamenz/", "series": "", "director": "", "release_date": "2024-03-31", "runtime": 41.83, "genres": ["おっぱい", "お尻", "素人"], "cover_urls": [COVER], "cover_url": COVER}) def test_the_shop_page_names_no_performers(self): # 标题里那个 `みお(19)` 是卖家自己写的宣传语,没有第二处可以印证;FC2 的演员 # 线索在 fc2cmadb 的评论区,走 `scripts/fetch_fc2_metadata.py`。 self.assertEqual(article(shop_page(), "FC2-PPV-4364209")["actresses"], []) def test_another_products_page_is_not_this_ones_data(self): # 站上的商品号会被复用给别的投稿,不核 sku 就把另一部片的资料写到这个番号头上。 with self.assertRaises(SourceFailure) as caught: article(shop_page(video="4470851"), "FC2-PPV-4364209") self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, "FC2 上没有这个商品")) def test_a_delisted_product_reads_as_absent_not_as_a_broken_page(self): with self.assertRaises(SourceFailure) as caught: article(gone_page(), "FC2-PPV-4364209") self.assertEqual((caught.exception.kind, str(caught.exception)), ("not_found", "FC2 上没有这个商品")) def test_a_seller_without_a_name_keeps_the_page_that_identifies_it(self): html = shop_page(seller="").replace( '', "") found = article(html, "FC2-PPV-4364209") self.assertEqual(found["label"], "https://adult.contents.fc2.com/users/otonakamenz/") def test_the_shop_is_asked_once_on_its_own_host_with_the_fc2_page_limit(self): transport = serve({"https://adult.contents.fc2.com/article/4364209/": shop_page()}) found = Fc2Source().records("FC2-PPV-4364209", session=Session(transport)) self.assertEqual([record.title for record in found], [TITLE]) self.assertEqual(transport.calls, [("https://adult.contents.fc2.com/article/4364209/", "https://adult.contents.fc2.com/", 2 * 1024 * 1024, None)]) self.assertEqual(FC2.page_limit, 2 * 1024 * 1024) class Fc2MirrorPageTests(unittest.TestCase): def test_the_mirror_carries_what_the_shop_has_dropped(self): found = mirror(mirror_page(), "FC2-PPV-3189161") self.assertEqual(found["id"], "FC2-PPV-3189161") self.assertEqual(found["source_url"], "https://fc2cmadb.com/articles/3189161") self.assertEqual(found["title"], "【無】コスプレシリーズ") self.assertEqual(found["release_date"], "2023-02-19") self.assertEqual(found["runtime"], 46.1) self.assertEqual(found["maker"], STUDIO) self.assertEqual(found["label"], "梨奈の射精動画@個人撮影") self.assertEqual(found["seller_url"], "https://adult.contents.fc2.com/users/rina_vlog/") self.assertEqual(found["genres"], ["ハメ撮り", "フェラ"]) def test_the_whole_payload_matches_the_snapshot_shape_key_by_key(self): self.assertEqual(mirror(mirror_page(), "FC2-PPV-3189161"), { "id": "FC2-PPV-3189161", "content_id": "3189161", "source_url": "https://fc2cmadb.com/articles/3189161", "title": "【無】コスプレシリーズ", "description": "", "actresses": [], "maker": "FC2-PPV", "label": "梨奈の射精動画@個人撮影", "seller_url": "https://adult.contents.fc2.com/users/rina_vlog/", "series": "", "director": "", "release_date": "2023-02-19", "runtime": 46.1, "genres": ["ハメ撮り", "フェラ"], "cover_urls": [COVER], "cover_url": COVER}) def test_the_named_women_come_from_the_second_ask_not_from_this_page(self): # 女优是这一页的延迟 prop:首屏那份 HTML 里一个人也没有,点名要过才有。 self.assertEqual(mirror(mirror_page(), "FC2-PPV-3189161")["actresses"], []) url = "https://fc2cmadb.com/articles/3189161" transport = serve({url: lambda headers: (mirror_actresses("野々宮すず") if headers.get("X-Inertia") else mirror_page())}) found = Fc2cmadbSource().query("FC2-PPV-3189161", session=Session(transport)) self.assertEqual(found.payload()["actresses"], [{ "japanese_name": "野々宮すず", "external_id": "3667", "alias_names": ["ののみやすず", "逢坂りの", "きたのあや"]}]) self.assertEqual(transport.calls, [(url, "https://fc2cmadb.com/", 2 * 1024 * 1024, None), (url, url, 2 * 1024 * 1024, "actresses")]) def test_a_failed_second_ask_keeps_everything_else_the_mirror_gave(self): # 那一跳撞上限流是常事,其余字段是站上最全的一份,不跟着丢。 url = "https://fc2cmadb.com/articles/3189161" transport = serve({url: lambda headers: None if headers.get("X-Inertia") else mirror_page()}) found = Fc2cmadbSource().query("FC2-PPV-3189161", session=Session(transport)) self.assertEqual((found.title, found.performers), ("【無】コスプレシリーズ", ())) self.assertEqual(len(transport.calls), 2) def test_the_second_ask_names_this_page_and_only_the_column_it_wants(self): headers = partial_headers(mirror_page()) self.assertEqual(headers["X-Inertia-Version"], MIRROR_VERSION) self.assertEqual(headers["X-Inertia-Partial-Component"], MIRROR_COMPONENT) self.assertEqual(headers["X-Inertia-Partial-Data"], "actresses") def test_a_page_that_is_not_a_product_page_is_never_asked_a_second_time(self): # 站上没有的商品回的是错误页,它照样带着版本号,问下去只会白花一趟配额。 self.assertEqual(partial_headers(mirror_page(component="Error")), {}) self.assertEqual(partial_headers(mirror_page(version="")), {}) self.assertEqual(partial_headers("
    "), {}) transport = serve({"https://fc2cmadb.com/articles/3189161": mirror_page(video="4364209")}) with self.assertRaises(SourceFailure): Fc2cmadbSource().query("FC2-PPV-3189161", session=Session(transport)) self.assertEqual(len(transport.calls), 1) def test_the_stage_names_a_woman_has_worn_are_not_more_women(self): # `alias_name` 那一串是同一个人的曾用名,一位女优挂着十几个。 found = parse_actresses(mirror_actresses("野々宮すず", "ゆうか")) self.assertEqual([one["japanese_name"] for one in found], ["野々宮すず", "ゆうか"]) self.assertEqual([one["external_id"] for one in found], ["3667", "3668"]) # 放在 `alias_names` 而不是 `aliases`:后者会跟着资料页证据被直接登记成别名。 self.assertNotIn("aliases", found[0]) self.assertEqual(found[0]["alias_names"], ["ののみやすず", "逢坂りの", "きたのあや"]) self.assertEqual(parse_actresses(mirror_actresses()), []) self.assertEqual(parse_actresses("429"), []) def test_the_mirror_cover_points_at_the_file_itself(self): # 镜像有时给的是缩放服务的地址。w276 只有 276 像素宽,连封面的最低宽度都过不了, # 而它后半截就是原件地址(实测同一个文件 2350×2352)。 wrapped = "https://contents-thumbnail2.fc2.com/w276/" + COVER.removeprefix("https://") found = mirror(mirror_page(image=wrapped), "FC2-PPV-3189161") self.assertEqual(found["cover_url"], COVER) self.assertEqual(found["cover_urls"], [COVER]) def test_the_mirrors_own_placeholder_is_not_a_cover(self): # 站上没有商品图的条目挂的是镜像自己那张占位件,还是个站内相对地址。当封面交 # 下去,抓取那侧连主机都拼不出来,报回来的是一句「来源连接未取得」。 found = mirror(mirror_page(image="/storage/images/article/no-image.jpg"), "FC2-PPV-3189161") self.assertEqual(found["cover_url"], "") self.assertEqual(found["cover_urls"], []) self.assertEqual(found["title"], "【無】コスプレシリーズ") def test_another_products_mirror_page_is_not_this_ones_data(self): for html in (mirror_page(video="4364209"), "
    "): with self.subTest(html=html[:40]), self.assertRaises(SourceFailure) as caught: mirror(html, "FC2-PPV-3189161") self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, "FC2CMADB 上没有这个商品")) def test_the_two_pages_hand_back_the_same_shape(self): # 两处的资料落进同一条候选流水线,字段少一个就是复核卡上少一格。 self.assertEqual(sorted(article(shop_page(), "FC2-PPV-4364209")), sorted(mirror(mirror_page(), "FC2-PPV-3189161"))) self.assertEqual((FC2.name, FC2CMADB.name), ("fc2", "fc2cmadb")) class JavArchiveTests(unittest.TestCase): def test_the_search_page_gives_up_the_link_that_carries_this_shop_number(self): self.assertEqual(links(search_page(), "FC2-PPV-4137487"), [ARCHIVE_LINK]) self.assertEqual(links(search_page().encode(), "fc2ppv4137487"), [ARCHIVE_LINK]) def test_a_longer_number_that_merely_starts_the_same_is_not_this_one(self): # 站上 `4137487` 与 `41374870` 都有;按子串比会把后者的标题和封面安到前者头上。 self.assertEqual(links(search_page(video="41374870"), "FC2-PPV-4137487"), []) self.assertEqual(links(search_page(), "FC2-PPV-9999999"), []) self.assertEqual(links(search_page(), "ORETD-615"), []) def test_one_search_page_holds_several_works_and_each_code_finds_its_own(self): # 一页里既有别的作品,也有侧栏的热门与归档,挑的必须是这个商品号那一条。 self.assertTrue(links(search_page(), "FC2-PPV-1863914")[0].startswith("/735702-")) self.assertTrue(links(search_page(), "FC2-PPV-4137487")[0].startswith("/926949-")) def test_every_repost_of_the_same_work_is_handed_over(self): # 不同转存者各发一次,图也各存各的:`1436028` 的 `/641159-` 那条是 404, # `/800025-` 那条有 1280×720。先后没有质量含义,所以两条都要。 found = links(search_page(reposted=True), "FC2-PPV-4137487") self.assertEqual([link.split("-")[0] for link in found], ["/926949", "/800025"]) def test_every_repost_becomes_its_own_record_and_a_dead_one_is_skipped(self): search = "https://javarchive.com/search?q=FC2-PPV-4137487" repost = "https://javarchive.com/800025-fc2ppv-4137487-離婚の後遺症で性欲が止まらない変態女-pn.html" both = serve({search: search_page(reposted=True), "https://javarchive.com" + ARCHIVE_LINK: archive_page(), repost: archive_page(large="{root}FC2PPV-{video}.jpg", small="")}) found = JavArchiveSource().records("FC2-PPV-4137487", session=Session(both)) self.assertEqual([record.cover_urls for record in found], [(ARCHIVE_PICTURES + "4137487pl.jpg", ARCHIVE_PICTURES + "4137487ps.jpg"), (ARCHIVE_PICTURES + "FC2PPV-4137487.jpg",)]) self.assertEqual([call[:3] for call in both.calls], [(search, "https://javarchive.com/", 2 * 1024 * 1024), ("https://javarchive.com" + ARCHIVE_LINK, "https://javarchive.com/", 2 * 1024 * 1024), (repost, "https://javarchive.com/", 2 * 1024 * 1024)]) # 第一条 404、第二条对不上番号时,只剩还在的那一条。 one = serve({search: search_page(reposted=True), repost: archive_page()}) self.assertEqual(len(JavArchiveSource().records("FC2-PPV-4137487", session=Session(one))), 1) stray = serve({search: search_page(reposted=True), repost: archive_page(video="4364209")}) with self.assertRaises(SourceFailure) as caught: JavArchiveSource().records("FC2-PPV-4137487", session=Session(stray)) self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, "JavArchive 上没有这个商品")) def test_a_search_without_this_shop_number_is_absent_and_a_refusal_keeps_its_tier(self): search = "https://javarchive.com/search?q=FC2-PPV-4137487" with self.assertRaises(SourceFailure) as caught: JavArchiveSource().records("FC2-PPV-4137487", session=Session(serve({search: search_page(video="41374870")}))) self.assertEqual(str(caught.exception), "JavArchive 上没有这个商品") def refused(request, timeout, limit): return HttpResponse(403, {}, b"", request.url) with self.assertRaises(SourceFailure) as caught: JavArchiveSource().records("FC2-PPV-4137487", session=Session(refused)) self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.AUTH_REQUIRED, "HTTP 403")) def test_the_archive_page_gives_the_title_without_the_code_in_front_of_it(self): found = archive(archive_page(), "FC2-PPV-4137487") self.assertEqual(found["title"], "Gカップ・脅威 マジ凄いです!脅威のパイパーGカップグラマラス!") self.assertEqual((found["id"], found["content_id"], found["maker"]), ("FC2-PPV-4137487", "4137487", STUDIO)) self.assertEqual(found["source_url"], "https://javarchive.com" + ARCHIVE_LINK) self.assertEqual(archive(archive_page(title="FC2PPV 4137487 素顔"), "FC2-PPV-4137487")["title"], "素顔") def test_the_whole_payload_matches_the_snapshot_shape_key_by_key(self): self.assertEqual(archive(archive_page(), "FC2-PPV-4137487"), { "id": "FC2-PPV-4137487", "content_id": "4137487", "source_url": "https://javarchive.com" + ARCHIVE_LINK, "title": ARCHIVE_TITLE, "description": "", "actresses": [], "maker": "FC2-PPV", "label": "", "seller_url": "", "series": "", "director": "", "release_date": "2023-11-21", "runtime": 50.05, "genres": ["ハメ撮り", "素人", "爆乳"], "cover_urls": [ARCHIVE_PICTURES + "4137487pl.jpg", ARCHIVE_PICTURES + "4137487ps.jpg"], "cover_url": ARCHIVE_PICTURES + "4137487pl.jpg"}) def test_the_cover_is_read_from_its_slot_whatever_the_transferrer_named_the_file(self): """封面认位置不认文件名:站上三种命名都有,按名字认的话两种一张都取不到。""" found = archive(archive_page(), "FC2-PPV-4137487") self.assertEqual(found["cover_url"], ARCHIVE_PICTURES + "4137487pl.jpg") self.assertEqual(found["cover_urls"], [ARCHIVE_PICTURES + "4137487pl.jpg", ARCHIVE_PICTURES + "4137487ps.jpg"]) # 转存者自己起的名字:`FC2PPV-4030617.jpg`、`FC2PPV835964-2.jpg`,都不带 `pl`/`ps`。 named = archive(archive_page(large="{root}FC2PPV-{video}.jpg", small="{root}FC2PPV{video}-2.jpg"), "FC2-PPV-4137487") self.assertEqual(named["cover_urls"], [ARCHIVE_PICTURES + "FC2PPV-4137487.jpg", ARCHIVE_PICTURES + "FC2PPV4137487-2.jpg"]) # 只有 schema.org 那个图位时它就是封面(实测 `835964` 的页面就少了前一个)。 self.assertEqual(archive(archive_page(large=""), "FC2-PPV-4137487")["cover_url"], ARCHIVE_PICTURES + "4137487ps.jpg") self.assertEqual(archive(archive_page(large="", small=""), "FC2-PPV-4137487")["cover_urls"], []) def test_the_stitched_preview_is_never_a_cover(self): # `_s.jpg` 是把多帧拼成的长条(实测 1024×2000),装上去就是一格拉长的马赛克。 found = archive(archive_page(large="", small=""), "FC2-PPV-4137487") self.assertEqual(found["cover_urls"], []) self.assertNotIn("_s.jpg", str(archive(archive_page(), "FC2-PPV-4137487")["cover_urls"])) def test_the_body_block_gives_up_the_tags_the_date_and_the_runtime(self): """转存者填了就取。2026-09-22 实测 4 部里只有 `4030617` 这块是齐的。""" found = archive(archive_page(), "FC2-PPV-4137487") self.assertEqual(found["genres"], ["ハメ撮り", "素人", "爆乳"]) self.assertEqual(found["release_date"], "2023-11-21") self.assertEqual(found["runtime"], 50.05) def test_an_empty_block_stays_empty_instead_of_taking_the_truncated_meta_line(self): """`` 那条 description 里有同一段话的截断版,取回来就是半截标签加一串属性。""" bare = archive(archive_page(tags="", release="", runtime=""), "FC2-PPV-4137487") self.assertEqual((bare["genres"], bare["release_date"], bare["runtime"]), ([], "", None)) # 站上没填标签时那一行写成 `--`,当成一个标签就入了库。 self.assertEqual(archive(archive_page(tags="--"), "FC2-PPV-4137487")["genres"], []) def test_another_products_archive_page_is_not_this_ones_data(self): for html, code, wording in ((archive_page(video="4364209"), "FC2-PPV-4137487", "JavArchive 上没有这个商品"), ("
    ", "FC2-PPV-4137487", "JavArchive 上没有这个商品"), (archive_page(), "ORETD-615", UNRECOGNISED)): with self.subTest(code=code, html=html[:40]), self.assertRaises(SourceFailure) as caught: archive(html, code) self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, wording)) def test_the_third_page_hands_back_the_same_shape_as_the_first_two(self): self.assertEqual(sorted(archive(archive_page(), "FC2-PPV-4137487")), sorted(mirror(mirror_page(), "FC2-PPV-3189161"))) self.assertEqual(JAVARCHIVE.name, "javarchive") self.assertEqual(JavArchiveSource().search_url("FC2-PPV-4137487"), "https://javarchive.com/search?q=FC2-PPV-4137487") self.assertEqual(JavArchiveSource().search_url("ORETD-615"), "") DB_COVER = "https://d39jz7pbpqkw9s.cloudfront.net/thumbnails/48/4898837.webp" def db_actress(at: int) -> str: """站内女优页的编号:标准写法的 uuid,实测 `5fb206c2-f8a0-4447-8e6e-c33869f54ea3`。""" return f"5fb206c2-f8a0-4447-8e6e-{at:012d}" def db_page(video="4898837", title="ふたりの巨乳美少女と3P。", seller="ぷにぷに製作所", slug="punipuni", sold="2026年5月10日", runtime="55:00", performers=("川北すずね",), leaked="流出なし", tags=("巨乳", "3P")): """fc2ppv-db.com 的作品页:Next.js 服务端渲染,「動画詳細情報」那块是嵌套的 div 与 p。 PR 位与関連動画也链到女优页,放进来看解析只认出演女優那一块。 """ cast = ("".join(f'{name}' f"{name}" for at, name in enumerate(performers)) if performers else "

    情報がありません

    ") marks = "".join(f'{tag}' for tag in tags) return ( '' f'' '
    ' f"

    FC2-PPV-{video} {title}

    " f'' "

    動画詳細情報

    " f"

    動画ID

    {video}

    " f"

    販売日

    {sold}

    " f"
    {leaked}モザイクあり
    " f"

    出演女優

    {cast}
    " f'
    {seller}' f"販売者{seller}
    " f"
    タグ
    {marks}
    " "
    " '' "" ) def db_missing_page(): """站上没有的商品回的也是 200,正文是它自己的 404 页。""" return "404

    404

    ページが見つかりません

    " def database(html, code): return Fc2ppvdbSource().parse(page(html, "https://fc2ppv-db.com/ja/videos/4898837"), code).payload() class Fc2ppvdbTests(unittest.TestCase): def test_the_database_page_gives_the_women_the_seller_the_date_and_the_leak_mark(self): found = database(db_page(), "FC2-PPV-4898837") self.assertEqual(found["title"], "ふたりの巨乳美少女と3P。") self.assertEqual(found["actresses"], [{"japanese_name": "川北すずね", "external_id": db_actress(0)}]) self.assertEqual((found["label"], found["seller_url"]), ("ぷにぷに製作所", "https://adult.contents.fc2.com/users/punipuni/")) self.assertEqual((found["release_date"], found["runtime"]), ("2026-05-10", 55.0)) self.assertEqual(found["genres"], ["巨乳", "3P"]) self.assertEqual(found["leaked"], False) self.assertEqual(database(db_page(leaked="流出あり"), "FC2-PPV-4898837")["leaked"], True) self.assertEqual((found["id"], found["content_id"], found["maker"], found["source_url"]), ("FC2-PPV-4898837", "4898837", STUDIO, "https://fc2ppv-db.com/ja/videos/4898837")) def test_the_thumbnail_is_not_handed_over_as_a_cover(self): # 站上那张是 360×360 的 CloudFront 缩略图(`og:image:width` 写 800,实测 360),封面留给存储原件那几档。 found = database(db_page(), "FC2-PPV-4898837") self.assertEqual((found["cover_urls"], found["cover_url"]), ([], "")) def test_only_the_cast_block_names_the_women_of_this_film(self): # PR 位与関連動画也链到女优页,那些不是这部片的人;站上没有女优时那一栏写「情報がありません」。 self.assertEqual(database(db_page(performers=("A", "B")), "FC2-PPV-4898837")["actresses"], [{"japanese_name": "A", "external_id": db_actress(0)}, {"japanese_name": "B", "external_id": db_actress(1)}]) self.assertEqual(database(db_page(performers=()), "FC2-PPV-4898837")["actresses"], []) def test_each_woman_carries_her_actress_page_id_once(self): # 同一位在栏里列两次只留一个;链接不是站内女优页的 uuid 时只交名字,不拿半截地址当编号。 doubled = database(db_page(performers=("A", "A")), "FC2-PPV-4898837")["actresses"] self.assertEqual(doubled, [{"japanese_name": "A", "external_id": db_actress(0)}]) odd = db_page(performers=("A",)).replace(f"/ja/actresses/{db_actress(0)}", "/ja/actresses/a-page") self.assertEqual(database(odd, "FC2-PPV-4898837")["actresses"], [{"japanese_name": "A"}]) def test_the_sale_date_falls_back_to_the_meta_line_and_japanese_dates_read_as_iso(self): self.assertEqual(japanese_date("2026年9月21日"), "2026-09-21") self.assertEqual(japanese_date("いつか"), "") missing = db_page().replace("

    販売日

    2026年5月10日

    ", "") self.assertEqual(database(missing, "FC2-PPV-4898837")["release_date"], "2026-05-09") def test_a_missing_product_and_a_challenge_page_each_keep_their_tier(self): for html, code, reason, wording in ( (db_missing_page(), "FC2-PPV-99999999", FailureReason.NOT_FOUND, "FC2PPV-DB 上没有这个商品"), (db_page(video="48988370"), "FC2-PPV-4898837", FailureReason.NOT_FOUND, "FC2PPV-DB 上没有这个商品"), (db_page(), "ORETD-615", FailureReason.NOT_FOUND, UNRECOGNISED), (CHALLENGE, "FC2-PPV-4898837", FailureReason.CLOUDFLARE_CHALLENGE, "FC2PPV-DB 要求 Cloudflare 验证,请在采集设置里更新 Cookie")): with self.subTest(code=code, html=html[:40]), self.assertRaises(SourceFailure) as caught: database(html, code) self.assertEqual((caught.exception.reason, str(caught.exception)), (reason, wording)) gate = page("年齢確認 | FC2PPV Database

    年齢確認

    ", "https://fc2ppv-db.com/ja/age-verify?returnTo=%2Fja%2Fvideos%2F4898837") with self.assertRaises(SourceFailure) as caught: Fc2ppvdbSource().parse(gate, "FC2-PPV-4898837") self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.AUTH_REQUIRED, "FC2PPV-DB 送到了年齢確認页,浏览器没有点过去"), "年龄门是会话状态不是这部片没有,不落 not_found") def test_the_database_is_asked_once_on_its_own_host_and_hands_back_the_same_shape(self): url = "https://fc2ppv-db.com/ja/videos/4898837" pages = serve({url: db_page()}) found = Fc2ppvdbSource().query("FC2-PPV-4898837", session=Session(pages)) self.assertEqual(found.title, "ふたりの巨乳美少女と3P。") self.assertEqual([call[:3] for call in pages.calls], [(url, "https://fc2ppv-db.com/", 2 * 1024 * 1024)]) self.assertEqual(FC2PPVDB.name, "fc2ppvdb") self.assertEqual(sorted(set(database(db_page(), "FC2-PPV-4898837")) - {"leaked"}), sorted(mirror(mirror_page(), "FC2-PPV-3189161"))) self.assertEqual(Fc2ppvdbSource().video_url("ORETD-615"), "") #: 假传输按解码后的地址找页,夹具地址就写原字。 TEN_ORIGINAL = "https://javten.com/video/1234567/id4898837/ふたり" TEN_STORAGE = "https://storage200000.contents.fc2.com/file/393/39269295/1778420440.09.png" TEN_GALLERY = "//contents-thumbnail2.fc2.com/w1280/storage200000.contents.fc2.com/file/393/39269295/1778420440.53.png" def ten_page(video="4898837", title="ふたりの巨乳美少女と3P。", seller="ぷにぷに製作所", runtime="55:00", published="2026-05-10T22:02:04+07:00", tags=("巨乳", "3P"), url=TEN_ORIGINAL, gallery=TEN_GALLERY): """javten.com 的作品页:番号在 `h1.fc2-id`,卖家与时长只在 description 里。""" marks = "".join(f'{tag}' for at, tag in enumerate(tags)) picture = (f'' if gallery else "") return ( f'' f'' f'' f'' f'' f'

    FC2-PPV-{video}

    {title}

    ' f"{picture}{marks}" f'この売り手からのすべてのビデオ' '繁體' "" ) def ten_results(*videos, lang=""): """搜索结果页:同一部片的日文原页与几个译文版都列着,还有标题里带数字的别的片。""" rows = "".join(f'FC2-PPV-{video}' f'EN' for at, video in enumerate(videos)) return f"{rows}别的片" def ten(html, code): return JavtenSource().parse(page(html, TEN_ORIGINAL), code).payload() class JavtenTests(unittest.TestCase): def test_the_work_page_gives_the_japanese_title_the_seller_the_tags_and_the_storage_originals(self): found = ten(ten_page(), "FC2-PPV-4898837") self.assertEqual(found["title"], "ふたりの巨乳美少女と3P。") self.assertEqual((found["label"], found["seller_url"]), ("ぷにぷに製作所", "")) self.assertEqual((found["release_date"], found["runtime"]), ("2026-05-10", 55.0)) self.assertEqual(found["genres"], ["巨乳", "3P"]) self.assertEqual(found["cover_urls"], [ TEN_STORAGE, "https://storage200000.contents.fc2.com/file/393/39269295/1778420440.53.png"]) self.assertEqual((found["id"], found["content_id"], found["maker"], found["source_url"], found["actresses"]), ("FC2-PPV-4898837", "4898837", STUDIO, TEN_ORIGINAL, [])) self.assertEqual(ten(ten_page(gallery=""), "FC2-PPV-4898837")["cover_urls"], [TEN_STORAGE]) def test_the_search_page_gives_up_only_this_shop_numbers_japanese_page(self): found = javten_module.links(ten_results("4898837", "1111111"), "FC2-PPV-4898837") self.assertEqual(found, ["https://javten.com/video/1000/id4898837/title-4898837"]) # 标题里带着这个号的别的片、以及 `id48988370` 都不是它。 self.assertEqual(javten_module.links(ten_results("48988370"), "FC2-PPV-4898837"), []) self.assertEqual(javten_module.links(ten_results("4898837"), "ORETD-615"), []) def test_a_single_hit_lands_on_the_work_page_and_a_translated_landing_fetches_the_original(self): search = "https://javten.com/search?kw=4898837" direct = serve({search: ten_page()}) found = JavtenSource().query("FC2-PPV-4898837", session=Session(direct)) self.assertEqual((found.title, found.source_url), ("ふたりの巨乳美少女と3P。", TEN_ORIGINAL)) self.assertEqual([call[0] for call in direct.calls], [search]) translated = "https://javten.com/tw/video/1234567/id4898837/ふたり" via_tw = serve({search: ten_page(url=translated), TEN_ORIGINAL: ten_page()}) JavtenSource().query("FC2-PPV-4898837", session=Session(via_tw)) self.assertEqual([call[:2] for call in via_tw.calls], [(search, "https://javten.com/"), (TEN_ORIGINAL, search)]) listed = serve({search: ten_results("4898837"), "https://javten.com/video/1000/id4898837/title-4898837": ten_page()}) JavtenSource().query("FC2-PPV-4898837", session=Session(listed)) self.assertEqual(len(listed.calls), 2) def test_a_translated_page_is_never_read_as_the_title(self): # 站上的中文是机器翻译,只收日文原页。 with self.assertRaises(SourceFailure) as caught: ten(ten_page(url="https://javten.com/tw/video/1234567/id4898837/x", title="兩個巨乳美少女的3P"), "FC2-PPV-4898837") self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.PARSE_ERROR, "JAVten 回的是译文页,只收日文原页")) def test_a_missing_product_and_a_challenge_page_each_keep_their_tier(self): search = "https://javten.com/search?kw=4898837" with self.assertRaises(SourceFailure) as caught: JavtenSource().query("FC2-PPV-4898837", session=Session(serve({search: ten_results("1111111")}))) self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, "JAVten 上没有这个商品")) with self.assertRaises(SourceFailure) as caught: JavtenSource().query("FC2-PPV-4898837", session=Session(serve({search: CHALLENGE}))) self.assertEqual((caught.exception.reason, caught.exception.status_code), (FailureReason.CLOUDFLARE_CHALLENGE, 403)) for html, code, wording in ((ten_page(video="48988370"), "FC2-PPV-4898837", "JAVten 上没有这个商品"), (ten_page(), "ORETD-615", UNRECOGNISED)): with self.subTest(code=code), self.assertRaises(SourceFailure) as caught: ten(html, code) self.assertEqual((caught.exception.reason, str(caught.exception)), (FailureReason.NOT_FOUND, wording)) def test_the_fifth_page_hands_back_the_same_shape_as_the_others(self): self.assertEqual(sorted(ten(ten_page(), "FC2-PPV-4898837")), sorted(mirror(mirror_page(), "FC2-PPV-3189161"))) self.assertEqual(JAVTEN.name, "javten") self.assertEqual(JavtenSource().search_url("FC2-PPV-4898837"), "https://javten.com/search?kw=4898837") self.assertEqual(JavtenSource().search_url("ORETD-615"), "") if __name__ == "__main__": unittest.main()