feat: add --post-url to fetch an arbitrary single post or reel
Lets an out-of-band link (shared by someone, not one of the tracked profiles) be pulled in directly by URL, filed under its owner's account like any other post. Bypasses profile planning, archive-db seeding, and the --min-interval floor entirely, since it's a single request rather than a recurring surface to budget against. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011qAds5qr7nZRq5R4yAuxUk
This commit is contained in:
+74
-5
@@ -331,6 +331,12 @@ def build_config(rate: str, sleep_request: list[float],
|
|||||||
"directory": [],
|
"directory": [],
|
||||||
"posts": post_like(POST_STEM),
|
"posts": post_like(POST_STEM),
|
||||||
"reels": post_like(POST_STEM),
|
"reels": post_like(POST_STEM),
|
||||||
|
# Ad hoc single-post fetches (--post-url) go through gallery-dl's
|
||||||
|
# own post/reel extractor instead of a profile listing, so the
|
||||||
|
# owning account is never known ahead of time -- only mid-
|
||||||
|
# extraction, same reasoning as highlights below.
|
||||||
|
"post": {**post_like(POST_STEM), "directory": ["{username}"]},
|
||||||
|
"reel": {**post_like(POST_STEM), "directory": ["{username}"]},
|
||||||
"stories": item_like(ITEM_STEM),
|
"stories": item_like(ITEM_STEM),
|
||||||
"highlights": {
|
"highlights": {
|
||||||
**item_like(ITEM_STEM),
|
**item_like(ITEM_STEM),
|
||||||
@@ -363,9 +369,11 @@ def gdl_command(src: Source, staging: Path, config: Path, cookies: str,
|
|||||||
cmd += ["--download-archive", str(archive_db)]
|
cmd += ["--download-archive", str(archive_db)]
|
||||||
# Forced destination -- never `{username}` -- because a reels tab returns
|
# Forced destination -- never `{username}` -- because a reels tab returns
|
||||||
# collab reels owned by other accounts, which would otherwise be filed
|
# collab reels owned by other accounts, which would otherwise be filed
|
||||||
# under the wrong profile. Highlights are the exception: their directory
|
# under the wrong profile. Highlights and ad hoc single-post fetches are
|
||||||
# embeds a title only known mid-extraction, so the config formats it.
|
# the exception: their directory is only known mid-extraction (a title, or
|
||||||
dest = staging if src.subcategory == "highlights" else staging / src.directory
|
# the post's owner), so the config formats it instead.
|
||||||
|
dest = (staging if src.subcategory in ("highlights", "post", "reel")
|
||||||
|
else staging / src.directory)
|
||||||
cmd += ["--destination", str(dest)]
|
cmd += ["--destination", str(dest)]
|
||||||
cmd.append(src.url)
|
cmd.append(src.url)
|
||||||
return cmd
|
return cmd
|
||||||
@@ -639,6 +647,54 @@ def publish(staging: Path, dest: str, dry_run: bool) -> int:
|
|||||||
return subprocess.run(cmd).returncode
|
return subprocess.run(cmd).returncode
|
||||||
|
|
||||||
|
|
||||||
|
def run_post_urls(args) -> int:
|
||||||
|
"""
|
||||||
|
Fetch one or more individual posts/reels by URL -- an ad hoc pull outside
|
||||||
|
the tracked profile list, e.g. a link shared from some other account. Each
|
||||||
|
is a single request, not a recurring surface, so there is no archive-db
|
||||||
|
seeding and no --min-interval floor to plan around.
|
||||||
|
"""
|
||||||
|
config = build_config(args.rate, list(args.sleep_request),
|
||||||
|
list(args.sleep), abort=0)
|
||||||
|
args.staging.mkdir(parents=True, exist_ok=True)
|
||||||
|
config_path = args.staging.parent / f"{args.staging.name}.gdl-config.json"
|
||||||
|
|
||||||
|
sources = [Source("post", url, "", "post") for url in args.post_url]
|
||||||
|
|
||||||
|
print(f"post-url : {len(sources)} to fetch")
|
||||||
|
print(f"pacing : {args.sleep_request[0]}-{args.sleep_request[1]}s between "
|
||||||
|
f"requests, rate cap {args.rate}")
|
||||||
|
print(f"staging : {args.staging}")
|
||||||
|
print(f"publish : {args.publish}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
if not args.execute:
|
||||||
|
for src in sources:
|
||||||
|
print(f" {src.url}")
|
||||||
|
print()
|
||||||
|
print(" " + " ".join(rsync_command(args.staging, args.publish, True)))
|
||||||
|
print("\ndry run; nothing fetched. pass --execute to run.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
config_path.write_text(json.dumps(config, indent=2))
|
||||||
|
failures = 0
|
||||||
|
for src in sources:
|
||||||
|
print(f"==> {src.url}")
|
||||||
|
cmd = gdl_command(src, args.staging, config_path, args.cookies,
|
||||||
|
args.archive_db)
|
||||||
|
result = subprocess.run(cmd)
|
||||||
|
if result.returncode != 0:
|
||||||
|
failures += 1
|
||||||
|
print(f" FAILED (exit {result.returncode})", file=sys.stderr)
|
||||||
|
|
||||||
|
print("\n==> publish")
|
||||||
|
if publish(args.staging, args.publish, dry_run=False) != 0:
|
||||||
|
failures += 1
|
||||||
|
|
||||||
|
print(f"\ndone; {failures} step(s) failed")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
# A sync runs for hours and is normally watched through a redirected log,
|
# A sync runs for hours and is normally watched through a redirected log,
|
||||||
# where Python's block buffering would withhold progress until it happened
|
# where Python's block buffering would withhold progress until it happened
|
||||||
@@ -649,10 +705,10 @@ def main() -> int:
|
|||||||
|
|
||||||
ap = argparse.ArgumentParser(description=__doc__,
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
ap.add_argument("--index", required=True,
|
ap.add_argument("--index",
|
||||||
help="existing archive listing: a local root, or the "
|
help="existing archive listing: a local root, or the "
|
||||||
"viewer's base URL (only a FILE LISTING is needed, "
|
"viewer's base URL (only a FILE LISTING is needed, "
|
||||||
"never the contents)")
|
"never the contents). Required unless --post-url")
|
||||||
ap.add_argument("--publish", required=True,
|
ap.add_argument("--publish", required=True,
|
||||||
help="rsync destination for fetched files; a local path or "
|
help="rsync destination for fetched files; a local path or "
|
||||||
"user@host:/path")
|
"user@host:/path")
|
||||||
@@ -665,6 +721,12 @@ def main() -> int:
|
|||||||
g.add_argument("--urls-file", type=Path,
|
g.add_argument("--urls-file", type=Path,
|
||||||
help="file of Instagram profile URLs or usernames, one per "
|
help="file of Instagram profile URLs or usernames, one per "
|
||||||
"line; # comments and blank lines allowed")
|
"line; # comments and blank lines allowed")
|
||||||
|
g.add_argument("--post-url", action="append", default=[],
|
||||||
|
help="fetch one post or reel by URL (e.g. "
|
||||||
|
"https://www.instagram.com/p/SHORTCODE/), filed under "
|
||||||
|
"its owner's account like any other post; repeatable. "
|
||||||
|
"A one-off fetch outside the tracked profile list: no "
|
||||||
|
"archive-db seeding, no --min-interval floor")
|
||||||
ap.add_argument("--cookies", default="chrome:/home/matt/.config/google-chrome-devtools",
|
ap.add_argument("--cookies", default="chrome:/home/matt/.config/google-chrome-devtools",
|
||||||
help="gallery-dl --cookies-from-browser value")
|
help="gallery-dl --cookies-from-browser value")
|
||||||
ap.add_argument("--archive-db", type=Path, default=None,
|
ap.add_argument("--archive-db", type=Path, default=None,
|
||||||
@@ -709,6 +771,13 @@ def main() -> int:
|
|||||||
print("rsync not on PATH", file=sys.stderr)
|
print("rsync not on PATH", file=sys.stderr)
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
|
if args.post_url:
|
||||||
|
return run_post_urls(args)
|
||||||
|
|
||||||
|
if not args.index:
|
||||||
|
print("--index is required unless --post-url is given", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
index = ArchiveIndex(args.index)
|
index = ArchiveIndex(args.index)
|
||||||
names = index.profiles()
|
names = index.profiles()
|
||||||
if args.urls_file:
|
if args.urls_file:
|
||||||
|
|||||||
@@ -209,6 +209,29 @@ class Publishing(unittest.TestCase):
|
|||||||
self.assertIn("--dry-run", cmd)
|
self.assertIn("--dry-run", cmd)
|
||||||
|
|
||||||
|
|
||||||
|
class PostUrl(unittest.TestCase):
|
||||||
|
"""--post-url: a one-off fetch outside the tracked profile list, whose
|
||||||
|
owning account is only known mid-extraction -- same reasoning as
|
||||||
|
highlights, so it must be exempted from the same forced-destination rule."""
|
||||||
|
|
||||||
|
def test_config_keys_a_username_directory(self):
|
||||||
|
config = gdl.build_config("1M", [6.0, 10.0], [3.0, 6.0])
|
||||||
|
for kind in ("post", "reel"):
|
||||||
|
self.assertEqual(
|
||||||
|
config["extractor"]["instagram"][kind]["directory"],
|
||||||
|
["{username}"])
|
||||||
|
|
||||||
|
def test_destination_is_not_forced_like_posts_and_reels(self):
|
||||||
|
staging = Path("/stage")
|
||||||
|
for subcategory in ("post", "reel"):
|
||||||
|
src = gdl.Source(subcategory, "https://www.instagram.com/p/ABC/",
|
||||||
|
"", subcategory)
|
||||||
|
cmd = gdl.gdl_command(src, staging, Path("/cfg.json"), "chrome:x",
|
||||||
|
None)
|
||||||
|
self.assertIn(str(staging), cmd)
|
||||||
|
self.assertNotIn(str(staging / subcategory), cmd)
|
||||||
|
|
||||||
|
|
||||||
class UrlsFile(unittest.TestCase):
|
class UrlsFile(unittest.TestCase):
|
||||||
def test_reads_every_form_a_person_might_paste(self):
|
def test_reads_every_form_a_person_might_paste(self):
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
|||||||
Reference in New Issue
Block a user